Compare commits

..

98 Commits

Author SHA1 Message Date
Tak Hoffman
36a7c93389 feat(workspace): add safe workspace reset command 2026-04-17 16:48:52 -05:00
Peter Steinberger
7b27d08e56 perf: lazy load system run config 2026-04-17 16:39:24 +01:00
Gustavo Madeira Santana
8de7aefe0a Tests: narrow embedded timeout wiring 2026-04-17 11:37:46 -04:00
Gustavo Madeira Santana
d6c90b5af1 Tests: avoid memory-search cold plugin loads 2026-04-17 11:37:46 -04:00
Peter Steinberger
2535331e94 perf: remove Matrix test polling 2026-04-17 16:32:57 +01:00
Peter Steinberger
acace04c35 perf: remove Matrix auth retry waits in tests 2026-04-17 16:30:04 +01:00
Chunyue Wang
0b3d876e74 fix(codex): prevent gateway crash when app-server subprocess terminates abruptly (#67947)
Fixes openclaw#67886. Handles stdin EPIPE in CodexAppServerClient by attaching an error handler, guarding writeMessage against writes after close, and aligning closeWithError cleanup with close.
2026-04-17 23:28:37 +08:00
Peter Steinberger
d565c2cc34 perf: add lightweight secret input runtime 2026-04-17 16:28:15 +01:00
Peter Steinberger
5f3bb53788 perf: skip missing Matrix IDB snapshot locks 2026-04-17 16:24:29 +01:00
Peter Steinberger
a954e2fb46 perf: narrow Matrix thread binding test imports 2026-04-17 16:21:48 +01:00
Peter Steinberger
b2b835fb18 perf: reduce Matrix monitor wait polling 2026-04-17 16:19:29 +01:00
Tak Hoffman
62703d8430 fix(bootstrap): workspace bootstrap prompt routing (#68000)
* fix(bootstrap): workspace bootstrap prompt routing

* Fix bootstrap routing edge cases

* Refine bootstrap mode routing and reset prompts

* Fix bootstrap workspace routing for embedded runs

* Fix embedded bootstrap compile follow-up

* Align bare reset bootstrap file access

* Honor reset override model for bootstrap gating

* Align chat reset bootstrap topology
2026-04-17 10:18:50 -05:00
Peter Steinberger
4d7d14cfa7 perf: flush Matrix event tests deterministically 2026-04-17 16:18:27 +01:00
Peter Steinberger
bac3d26fe7 perf: narrow Matrix reaction approval imports 2026-04-17 16:17:01 +01:00
Peter Steinberger
2a0a498b0d perf: speed Matrix handler tests 2026-04-17 16:14:28 +01:00
Peter Steinberger
3b81bf4c7c perf: narrow Matrix handler session store import 2026-04-17 16:09:10 +01:00
Peter Steinberger
40c9da1d57 perf: avoid Matrix entry full-channel load in tests 2026-04-17 16:06:26 +01:00
Peter Steinberger
48aa076d12 perf: optimize remaining core tests 2026-04-17 16:05:10 +01:00
Peter Steinberger
310b5e4f6a test: reduce core command hotspots 2026-04-17 16:05:10 +01:00
Peter Steinberger
418056f7a0 perf: narrow plugin SDK import surfaces 2026-04-17 16:05:09 +01:00
Peter Steinberger
af954a81d1 perf: optimize bundled extension tests 2026-04-17 16:05:09 +01:00
Peter Steinberger
605cb60586 perf: optimize Matrix test boundaries 2026-04-17 16:05:09 +01:00
Peter Steinberger
a861da41b5 test: trim CLI and doctor hotspots 2026-04-17 16:05:09 +01:00
Peter Steinberger
199bb1fe05 test: mock auth alias registry in onboarding auth 2026-04-17 16:05:09 +01:00
Peter Steinberger
d3e12cee7e test: move gateway token cases to unit seam 2026-04-17 16:05:09 +01:00
Peter Steinberger
d3b70f9823 test: tighten message and onboarding hotspots 2026-04-17 16:05:09 +01:00
Peter Steinberger
f7f88e52e4 test: trim doctor and auth choice hotspots 2026-04-17 16:05:09 +01:00
Peter Steinberger
675eb38ad0 test: keep provider auth onboarding config in memory 2026-04-17 16:05:09 +01:00
Peter Steinberger
a90daa5759 test: narrow channel token summary coverage 2026-04-17 16:05:09 +01:00
Peter Steinberger
e477125608 test: merge repeated onboarding auth cases 2026-04-17 16:05:09 +01:00
Peter Steinberger
7995d43625 test: merge provider auth onboarding cases 2026-04-17 16:05:09 +01:00
Peter Steinberger
68cf9e52a2 test: narrow auth credential fixtures 2026-04-17 16:05:09 +01:00
Peter Steinberger
e53a8bd865 test: trim provider auth onboarding fixtures 2026-04-17 16:05:09 +01:00
Peter Steinberger
290371399f test: mock agent command runtime seams 2026-04-17 16:05:09 +01:00
Peter Steinberger
e2099301c5 test: narrow auth choice provider fixtures 2026-04-17 16:05:09 +01:00
Peter Steinberger
f810cc4d58 test: lower matrix bind coverage boundary 2026-04-17 16:05:09 +01:00
Peter Steinberger
efb37f8949 test: narrow channels status command mocks 2026-04-17 16:05:09 +01:00
Peter Steinberger
c93b2540ec test: mock status command scan seam 2026-04-17 16:05:09 +01:00
Peter Steinberger
ab726235bd test: narrow agents bind and provider auth cases 2026-04-17 16:05:09 +01:00
Peter Steinberger
824b5e4d91 test: mock agent runtime secret targets 2026-04-17 16:05:09 +01:00
Peter Steinberger
bb70b41340 test: split lightweight agent session coverage 2026-04-17 16:05:09 +01:00
Peter Steinberger
a9fab78f64 test: trim duplicate auth choice integration cases 2026-04-17 16:05:09 +01:00
Peter Steinberger
e4f04d92a3 test: move custom onboarding edge cases down-stack 2026-04-17 16:05:09 +01:00
Peter Steinberger
24ef516879 test: trim duplicate direct provider token cases 2026-04-17 16:05:09 +01:00
Peter Steinberger
2e3ef1b9e1 fix: pass message routing context to send actions 2026-04-17 16:05:09 +01:00
Peter Steinberger
4ac8b08265 test: mock agent runtime config imports 2026-04-17 16:05:09 +01:00
Peter Steinberger
e00f9c7a9d test: trim custom provider onboarding duplicates 2026-04-17 16:05:09 +01:00
Peter Steinberger
e19e94ef07 test: split channels list auth profile coverage 2026-04-17 16:05:09 +01:00
Peter Steinberger
cfba24fa3c test: move open policy repair cases to unit seam 2026-04-17 16:05:08 +01:00
Peter Steinberger
8ebb3ff0d4 test: narrow message command mocks 2026-04-17 16:05:08 +01:00
Peter Steinberger
4451e8479a test: mock channels status command seam 2026-04-17 16:05:08 +01:00
Peter Steinberger
271fc360e7 test: mock message command config seam 2026-04-17 16:05:08 +01:00
Peter Steinberger
82355d1d9f test: isolate agent runtime config imports 2026-04-17 16:05:08 +01:00
Peter Steinberger
769a09842d test: narrow channels command test imports 2026-04-17 16:05:08 +01:00
Gustavo Madeira Santana
82fe6f50ef QA: organize scenarios by theme 2026-04-17 11:03:47 -04:00
Val Alexander
a45ebf3281 fix(ui): reset settings scroll and align details headers (#68150) thanks @BunsDev
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-17 09:55:30 -05:00
Val Alexander
be7a415eb0 fix: preserve hello-ok scopes for reused device tokens (#68039) 2026-04-17 03:20:48 -05:00
Val Alexander
f377db1015 feat: add macOS screen snapshots for monitor preview (#67954) thanks @BunsDev
Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-04-17 02:58:21 -05:00
Val Alexander
0b6c39be18 fix: report shared auth scopes in hello-ok (#67810) thanks @BunsDev
Co-authored-by: Val Alexander <bunsthedev@gmail.com>
2026-04-17 02:48:30 -05:00
Gustavo Madeira Santana
3ea1bf4232 Auto-reply: avoid eager bundled route fallback 2026-04-17 03:36:13 -04:00
Gustavo Madeira Santana
54e4e16844 Tests: narrow session binding contract setup 2026-04-17 03:36:13 -04:00
J. Tyler Bittner
00951dc9f9 fix(macOS): enable undo/redo in webchat composer text input (#34962)
* fix(macOS): enable undo/redo in webchat composer text input

Set `allowsUndo = true` on ChatComposerNSTextView in makeNSView().
NSTextView defaults allowsUndo to false, which prevented Cmd+Z and
the Edit menu Undo/Redo items from functioning.

Fixes #34898

* fix(macos): enable webchat composer undo/redo (#34962) (thanks @tylerbittner)

---------

Co-authored-by: Nimrod Gutman <nimrod.gutman@gmail.com>
2026-04-17 10:07:20 +03:00
Gustavo Madeira Santana
82b529a6d9 Tests: speed up channel setup promotion 2026-04-17 02:58:24 -04:00
Gustavo Madeira Santana
5775fe272a Docs: refresh agent instructions 2026-04-17 02:46:38 -04:00
Viz
8e79080bef fix(auth): serialize OAuth refresh across agents to fix #26322 (#67876) 2026-04-16 23:44:03 -07:00
Peter Steinberger
7d4f1a6777 test: allow ollama public surface boundary test 2026-04-17 07:28:09 +01:00
Gustavo Madeira Santana
89706d323c Docs: add test performance guardrails 2026-04-17 02:23:49 -04:00
Gustavo Madeira Santana
e4c4f955b3 Tests: restore context-engine usage proof 2026-04-17 02:23:49 -04:00
Gustavo Madeira Santana
74c198f2e8 Tests: slim context engine runtime coverage 2026-04-17 02:23:49 -04:00
Peter Steinberger
0ee5baf6c5 ci: retry failed custom checkouts 2026-04-17 07:20:51 +01:00
Peter Steinberger
1ffc02e930 test: trim duplicate provider auth onboarding cases 2026-04-17 07:19:23 +01:00
EE
1ce2596195 matrix: fix sessions_spawn --thread subagent session spawning (#67643)
Merged via squash.

Prepared head SHA: 1e5127e217
Co-authored-by: eejohnso-ops <238848106+eejohnso-ops@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-17 02:17:56 -04:00
Peter Steinberger
857b9cd326 test: reduce auth choice fixture churn 2026-04-17 07:15:28 +01:00
Peter Steinberger
9d5ab4a54c test: mock health status config boundaries 2026-04-17 07:15:28 +01:00
Peter Steinberger
299694d721 test: mock onboard config io boundary 2026-04-17 07:15:28 +01:00
Peter Steinberger
2713089220 test: mock legacy state plugin boundaries 2026-04-17 07:15:28 +01:00
Peter Steinberger
b945248650 test: mock channel install boundaries 2026-04-17 07:15:28 +01:00
Peter Steinberger
b1a3ad49a4 test: mock doctor preview channel boundaries 2026-04-17 07:15:27 +01:00
Peter Steinberger
c66f16ac55 test: trim doctor command hotspots 2026-04-17 07:15:27 +01:00
Peter Steinberger
92859357bb test: isolate agent auth and spawn hotspots 2026-04-17 07:15:27 +01:00
Peter Steinberger
dd9d2ebd01 test: stabilize MCP startup disposal race 2026-04-17 07:15:27 +01:00
Peter Steinberger
5817a76236 test: merge browser contract server suites 2026-04-17 07:15:27 +01:00
Peter Steinberger
a0d9598425 test: narrow ollama provider discovery setup 2026-04-17 07:15:27 +01:00
Peter Steinberger
daaebb8558 test: split browser snapshot target helper 2026-04-17 07:15:27 +01:00
Peter Steinberger
f57ce21d73 test: trim process-backed agent assertions 2026-04-17 07:15:27 +01:00
Peter Steinberger
b71c91022b test: collapse exec preflight parser cases 2026-04-17 07:15:27 +01:00
Peter Steinberger
d07c921ae3 test: trim provider extra-param imports 2026-04-17 07:15:27 +01:00
Peter Steinberger
132d3c76a0 test: reuse browser server harness imports 2026-04-17 07:15:27 +01:00
Peter Steinberger
35dcd06764 test: trim agent test hotspots 2026-04-17 07:15:27 +01:00
Gustavo Madeira Santana
7ae670e501 Tests: fast-path Slack message tool discovery 2026-04-17 02:00:26 -04:00
Gustavo Madeira Santana
878f2122e5 Tests: fast-path Matrix ACP thread binding 2026-04-17 02:00:26 -04:00
Gustavo Madeira Santana
807c6648f9 Tests: fast-path gateway auth bypass discovery 2026-04-17 02:00:26 -04:00
Gustavo Madeira Santana
178c36532d Tests: isolate perf-sensitive env state 2026-04-17 02:00:26 -04:00
Ayaan Zaidi
26f7198eda fix(memory-core): preserve vector dims on readonly recovery 2026-04-17 11:22:56 +05:30
Ayaan Zaidi
bec52e5f7e fix: clear compaction replay after visible boundaries (#67993) 2026-04-17 11:18:22 +05:30
Ayaan Zaidi
5aad79571e fix(telegram): clear compaction replay after visible boundaries 2026-04-17 11:18:22 +05:30
Ayaan Zaidi
671579663b fix: preserve post-stream error payloads (#67991)
* fix(reply): preserve post-stream error payloads

* fix: preserve post-stream error payloads (#67991)
2026-04-17 11:11:37 +05:30
Rubén Cuevas
7b0e950e09 fix: dedupe degraded sqlite-vec warnings (#67898) (thanks @rubencu)
* Agents: dedupe bootstrap truncation warnings

* Memory: dedupe sqlite-vec degradation warnings

* Memory: align degraded vector warning

* test(memory-core): remove stale vector warning arg

* fix(memory-core): reset degraded warning on vector reset

* fix(memory-core): preserve warning latch across reindex rollback

* fix: dedupe degraded sqlite-vec warnings (#67898) (thanks @rubencu)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-17 11:09:14 +05:30
578 changed files with 21388 additions and 16598 deletions

View File

@@ -52,7 +52,11 @@ function ghGraphQL(query, options = {}) {
function failOnGraphQLFailure(result, message) {
if (result?.gh_failed) {
const details = (result.stderr || result.stdout || `gh exited with status ${result.status}`).trim();
const details = (
result.stderr ||
result.stdout ||
`gh exited with status ${result.status}`
).trim();
fail(`${message}: ${details}`);
}
if (Array.isArray(result?.errors) && result.errors.length > 0) {
@@ -73,9 +77,7 @@ function formatGraphQLAfterClause(cursor) {
}
function findDiscussionCommentNode(nodes, discussionCommentDbId) {
return (
nodes.find((node) => String(node.databaseId) === String(discussionCommentDbId)) || null
);
return nodes.find((node) => String(node.databaseId) === String(discussionCommentDbId)) || null;
}
function fetchDiscussionReplyPage(commentNodeId, cursor) {
@@ -169,9 +171,13 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
while (!reply && hasMoreReplies) {
const replyPage = fetchDiscussionReplyPage(topLevelComment.id, replyCursor);
failOnGraphQLFailure(replyPage, `Failed to fetch replies for discussion comment ${topLevelComment.id}`);
failOnGraphQLFailure(
replyPage,
`Failed to fetch replies for discussion comment ${topLevelComment.id}`,
);
const replies = replyPage?.data?.node?.replies;
if (!replies) fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`);
if (!replies)
fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`);
reply = findDiscussionCommentNode(replies.nodes, discussionCommentDbId);
hasMoreReplies = replies.pageInfo.hasNextPage;
@@ -189,9 +195,7 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
}
function createDiscussionComment(discussionNodeId, body, replyToNodeId) {
const replyToClause = replyToNodeId
? `, replyToId: "${escapeGraphQLString(replyToNodeId)}"`
: "";
const replyToClause = replyToNodeId ? `, replyToId: "${escapeGraphQLString(replyToNodeId)}"` : "";
const result = ghGraphQL(
`mutation { addDiscussionComment(input: { discussionId: "${escapeGraphQLString(discussionNodeId)}"${replyToClause}, body: "${escapeGraphQLString(body)}" }) { comment { id url } } }`,
);
@@ -261,7 +265,10 @@ function cmdFetchContent(locationJson) {
const discussionNumber = urlMatch[1];
const discussionCommentDbId = urlMatch[2];
const { discussionId, comment } = fetchDiscussionComment(discussionNumber, discussionCommentDbId);
const { discussionId, comment } = fetchDiscussionComment(
discussionNumber,
discussionCommentDbId,
);
if (!comment)
fail(
`Discussion comment #${discussionCommentDbId} not found in discussion #${discussionNumber}`,

View File

@@ -677,9 +677,10 @@ jobs:
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target"
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA"
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
echo "checkout attempt ${attempt}/2 succeeded"
}

View File

@@ -1,12 +1,12 @@
# Repository Guidelines
- Repo: https://github.com/openclaw/openclaw
- In chat replies, file references must be repo-root relative only (example: `src/telegram/index.ts:80`); never absolute paths or `~/...`.
- In chat replies, file references must be repo-root relative only (example: `extensions/telegram/src/index.ts:80`); never absolute paths or `~/...`.
- Do not edit files covered by security-focused `CODEOWNERS` rules unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted surfaces, not drive-by cleanup.
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, infra in `src/infra`, media pipeline in `src/media`, web provider helpers in `src/web` and `src/plugins/web-*provider*.ts`).
- Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Nomenclature: use "plugin" / "plugins" in docs, UI, changelogs, and contributor guidance. The bundled workspace plugin tree remains the internal package layout to avoid repo-wide churn from a rename.
@@ -17,8 +17,8 @@
- Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
- Core channel docs: `docs/channels/`
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
- Bundled plugin channels: the workspace plugin tree (for example Matrix, Zalo, ZaloUser, Voice Call)
- Core channel code: `src/channels`, `src/routing`, `src/web`
- Bundled plugin channels: `extensions/<channel>/` (for example Discord, Telegram, Slack, Matrix, Zalo, ZaloUser, Voice Call)
- When adding channels/plugins/apps/docs, update `.github/labeler.yml` and create matching GitHub labels (use existing channel/plugin label colors).
## Architecture Boundaries
@@ -73,7 +73,7 @@
- `hooks.internal.entries` is the canonical public hook config model. `hooks.internal.handlers` is compatibility-only input and must not be re-exposed in public schema/help/baseline surfaces.
- Bundled plugin contract boundary:
- Public docs: `docs/plugins/architecture.md`, `docs/plugins/manifest.md`, `docs/plugins/sdk-overview.md`
- Definition files: `src/plugins/contracts/registry.ts`, `src/plugins/types.ts`, `src/plugins/public-artifacts.ts`
- Definition files: `src/plugins/contracts/registry.ts`, `src/plugins/types.ts`, `src/plugins/public-surface-loader.ts`, `src/plugins/public-surface-runtime.ts`, `src/plugins/provider-public-artifacts.ts`, `src/plugins/web-provider-public-artifacts.ts`
- Rule: keep manifest metadata, runtime registration, public SDK exports, and contract tests aligned. Do not create a hidden path around the declared plugin interfaces.
- Extension test boundary:
- Keep extension-owned onboarding/config/provider coverage under the owning bundled plugin package when feasible.
@@ -87,6 +87,8 @@
- `src/plugin-sdk/AGENTS.md` expands public SDK contract rules.
- `src/plugins/AGENTS.md` expands plugin loading, registry, and manifest rules.
- `src/gateway/protocol/AGENTS.md` expands typed Gateway protocol rules.
- `src/gateway/AGENTS.md` expands Gateway server hot-path and plugin artifact rules.
- `src/agents/AGENTS.md` expands agent test/import performance rules.
- `test/helpers/AGENTS.md` and `test/helpers/channels/AGENTS.md` expand shared test helper boundary rules.
- Plugin architecture direction:
- Keep a manifest-first control plane: discovery, validation, enablement, setup hints, and activation planning should stay metadata-driven by default.
@@ -117,8 +119,8 @@
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install`
- If deps are missing (for example `node_modules` missing, `vitest not found`, or `command not found`), run the repos package-manager install command (prefer lockfile/README-defined PM), then rerun the exact requested command once. Apply this to test/build/lint/typecheck/dev commands; if retry still fails, report the command and first actionable error.
- Pre-commit hooks: `prek install`. The hook runs the repo verification flow, including `pnpm check`.
- `FAST_COMMIT=1` skips the repo-wide `pnpm format` and `pnpm check` inside the pre-commit hook only. Use it when you intentionally want a faster commit path and are running equivalent targeted verification manually. It does not change CI and does not change what `pnpm check` itself does.
- Pre-commit hooks are installed by the package `prepare` script (`git config core.hooksPath git-hooks`). The hook formats/lints staged source files and runs `pnpm check` unless the staged change is docs-only or `FAST_COMMIT=1` is set.
- `FAST_COMMIT=1` skips the repo-wide `pnpm check` inside the pre-commit hook only. The hook still runs targeted formatting/linting for staged files and restages formatter changes. Use it when you intentionally want a faster commit path and are running equivalent targeted verification manually. It does not change CI and does not change what `pnpm check` itself does.
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`.
@@ -128,8 +130,8 @@
- TypeScript checks: `pnpm tsgo`
- Lint/format: `pnpm check`
- Local agent/dev shells default to host-aware `OPENCLAW_LOCAL_CHECK=1` behavior for `pnpm tsgo` and `pnpm lint`; set `OPENCLAW_LOCAL_CHECK_MODE=throttled` to force the lower-memory profile, `OPENCLAW_LOCAL_CHECK_MODE=full` to keep lock-only behavior, or `OPENCLAW_LOCAL_CHECK=0` in CI/shared runs.
- Format check: `pnpm format` (oxfmt --check)
- Format fix: `pnpm format:fix` (oxfmt --write)
- Format check: `pnpm format:check` (oxfmt --check)
- Format fix: `pnpm format` or `pnpm format:fix` (oxfmt --write)
- Terminology:
- "gate" means a verification command or command set that must be green for the decision you are making.
- A local dev gate is the fast default loop, usually `pnpm check` plus any scoped test you actually need.
@@ -137,8 +139,8 @@
- A CI gate is whatever the relevant workflow enforces for that lane (for example `check`, `check-additional`, `build-smoke`, or release validation).
- Local dev gate: prefer `pnpm check` for the normal edit loop. It keeps the repo-architecture policy guards out of the default local loop.
- CI architecture gate: `check-additional` enforces architecture and boundary policy guards that are intentionally kept out of the default local loop.
- Formatting gate: the pre-commit hook runs `pnpm format` before `pnpm check`. If you want a formatting-only preflight locally, run `pnpm format` explicitly.
- If you need a fast commit loop, `FAST_COMMIT=1 git commit ...` skips the hooks repo-wide `pnpm format` and `pnpm check`; use that only when you are deliberately covering the touched surface some other way.
- Formatting gate: the pre-commit hook runs targeted formatting on staged source files before `pnpm check`. If you want a repo-wide formatting-only preflight locally, run `pnpm format:check` explicitly.
- If you need a fast commit loop, `FAST_COMMIT=1 git commit ...` skips the hooks repo-wide `pnpm check`; targeted formatting/linting still runs, so use that only when you are deliberately covering the touched surface some other way.
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
- Generated baseline drift detection uses SHA-256 hash files under `docs/.generated/` (`.sha256` files tracked in git; full JSON baselines are gitignored, generated locally for inspection).
- Config schema drift uses `pnpm config:docs:gen` / `pnpm config:docs:check`.
@@ -215,6 +217,8 @@
- Test performance guardrail: when production code already accepts `deps`, callbacks, or runtime injection, use that seam in tests before adding module-level mocks.
- Test performance guardrail: prefer narrow public SDK subpaths such as `models-provider-runtime`, `skill-commands-runtime`, and `reply-dispatch-runtime` over older broad helper barrels when both expose the needed helper.
- Test performance guardrail: treat import-dominated test time as a boundary bug. Refactor the import surface before adding more cases to the slow file.
- Test performance guardrail: when replacing a slow integration test with helper-level coverage, extract the exact production composition into a named helper and test that helper. Do not trade coverage shape for speed without preserving the behavior proof somewhere cheaper.
- Test performance guardrail: for plugin-owned static descriptors used by core tests or cold paths, prefer lightweight public artifacts with full-runtime fallback over loading broad bundled plugin barrels.
- Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat.
- For targeted/local debugging, use the native root-project entrypoint: `pnpm test <path-or-filter> [vitest args...]` (for example `pnpm test src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses the repo's default config/profile/pool routing.
- Do not set test workers above 16; tried already.
@@ -250,8 +254,8 @@
## Security & Configuration Tips
- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out.
- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable.
- Channel/provider state lives under `~/.openclaw/credentials/`; rerun `openclaw channels login` if logged out. Model auth profiles live under `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`; legacy OAuth import still reads `~/.openclaw/credentials/oauth.json`.
- Pi sessions live under `~/.openclaw/agents/<agentId>/sessions/` by default; `session.store` can override the session store path.
- Environment variables: see `~/.profile`.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook, `docs/reference/RELEASING.md` for the public release policy, and `$openclaw-release-maintainer` for the maintainership workflow.
@@ -268,13 +272,13 @@
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); dont hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the OpenClaw Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep openclaw` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- Gateway may run as an app-managed launchd job. Restart the gateway via the app or `openclaw gateway restart`; inspect with `openclaw gateway status --deep` or, for the default profile, `launchctl print gui/$UID/ai.openclaw.gateway`. Use `scripts/restart-mac.sh` when you need to rebuild/relaunch the local macOS app itself. The app LaunchAgent uses `ai.openclaw.mac`. **When debugging on macOS, start/stop the gateway via the app or gateway CLI, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the OpenClaw subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; dont introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), and Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
- "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release).
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/version.json` (source for generated iOS config and Fastlane metadata), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), and `docs/install/updating.md` (pinned npm version).
- "Bump version everywhere" means all version locations above, then run `pnpm ios:version:sync` for iOS generated outputs. Only touch appcast metadata when cutting a new macOS Sparkle release.
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
- Mobile pairing: `ws://` (cleartext) is allowed for private LAN addresses (RFC 1918, link-local, mDNS `.local`) and loopback. Private LAN hosts typically lack PKI-backed identity, so requiring TLS there adds complexity without meaningful security gain. `wss://` is required for Tailscale and public endpoints.

View File

@@ -4,10 +4,15 @@ Docs: https://docs.openclaw.ai
## Unreleased
### Changes
- macOS/gateway: add `screen.snapshot` support for macOS app nodes, including runtime plumbing, default macOS allowlisting, and docs for monitor preview flows. (#67954) Thanks @BunsDev.
### Fixes
- Agents/bootstrap: resolve bootstrap from workspace truth instead of stale session transcript markers, keep embedded bootstrap instructions on the hidden user-context path, suppress normal `/new` and `/reset` greetings while `BOOTSTRAP.md` is still pending, and make the embedded runner read the bootstrap ritual before replying normally.
- Agents/bootstrap: resolve bootstrap from workspace truth instead of stale session transcript markers, keep embedded bootstrap instructions on a hidden user-context prelude, suppress normal `/new` and `/reset` greetings while `BOOTSTRAP.md` is still pending, and make the embedded runner read the bootstrap ritual before replying normally.
- Onboarding/non-interactive: preserve existing gateway auth tokens during re-onboard so active local gateway clients are not disconnected by an implicit token rotation. (#67821) Thanks @BKF-Gitty.
- Gateway/hello-ok: always report negotiated auth metadata for successful shared-auth handshakes, including control-ui bypass coverage when no device token is issued. (#67810) Thanks @BunsDev.
- OpenAI Codex/Responses: unify native Responses API capability detection so Codex OAuth requests emit the required `store: false` field on the native Responses path. (#67918) Thanks @obviyus.
- WhatsApp/setup: guard personal-phone and allowlist prompt values so setup fails with clear validation errors instead of crashing on undefined prompt text. (#67895) Thanks @lawrence3699.
- Models/config: preserve an existing `models.json` provider `baseUrl` during merge-mode regeneration so custom endpoints do not get reset on restart. (#67893) Thanks @lawrence3699.
@@ -15,6 +20,11 @@ Docs: https://docs.openclaw.ai
- Plugins/webhooks: enforce synchronous plugin registration with full rollback of failed plugin side effects, and cache SecretRef-backed webhook auth per route so plugin startup and inbound webhook auth stay deterministic. (#67941) Thanks @obviyus.
- Telegram/ACP bindings: drop persisted DM bindings that still point at missing or failed ACP sessions on restart, while preserving plugin-owned bindings and uncertain store reads. (#67822) Thanks @chinar-amrutkar.
- Telegram/streaming: keep a transient preview on the same Telegram message when auto-compaction retries an in-flight answer, so streamed replies no longer appear duplicated after compaction. (#66939) Thanks @rubencu.
- Memory/sqlite-vec: emit the degraded sqlite-vec warning once per degraded episode instead of repeating it for every file write, while preserving the latch across safe-reindex rollback and resetting it when vector state is genuinely rebuilt. (#67898) Thanks @rubencu.
- Reply/block streaming: preserve post-stream incomplete-turn error payloads after block streaming already emitted content, so users get the warning instead of silence. (#67991) Thanks @obviyus.
- Telegram/streaming: clear the compaction replay guard after visible non-final boundaries so a post-tool assistant reply rotates to a fresh preview instead of editing the pre-compaction message. (#67993) Thanks @obviyus.
- Matrix: fix `sessions_spawn --thread` subagent session spawning — thread binding creation, cleanup on session end, and completion-message delivery target resolution now work end-to-end. (#67643) Thanks @eejohnso-ops and @gumadeiras.
- macOS/webchat: enable Undo and Redo in the composer text input by turning on the native `NSTextView` undo manager. (#34962) Thanks @tylerbittner.
## 2026.4.15
@@ -22,6 +32,12 @@ Docs: https://docs.openclaw.ai
- Anthropic/models: default Anthropic selections, `opus` aliases, Claude CLI defaults, and bundled image understanding to Claude Opus 4.7.
- Google/TTS: add Gemini text-to-speech support to the bundled `google` plugin, including provider registration, voice selection, WAV reply output, PCM telephony output, and setup/docs guidance. (#67515) Thanks @barronlroth.
- Control UI/Overview: add a Model Auth status card showing OAuth token health and provider rate-limit pressure at a glance, with attention callouts when OAuth tokens are expiring or expired. Backed by a new `models.authStatus` gateway method that strips credentials and caches for 60s. (#66211) Thanks @omarshahine.
- Memory/LanceDB: add cloud storage support to `memory-lancedb` so durable memory indexes can run on remote object storage instead of local disk only. (#63502) Thanks @rugvedS07.
- GitHub Copilot/memory search: add a GitHub Copilot embedding provider for memory search, and expose a dedicated Copilot embedding host helper so plugins can reuse the transport while honoring remote overrides, token refresh, and safer payload validation. (#61718) Thanks @feiskyer and @vincentkoc.
- Agents/local models: add experimental `agents.defaults.experimental.localModelLean: true` to drop heavyweight default tools like `browser`, `cron`, and `message`, reducing prompt size for weaker local-model setups without changing the normal path. (#66495) Thanks @ImLukeF.
- Packaging/plugins: localize bundled plugin runtime deps to their owning extensions, trim the published docs payload, and tighten install/package-manager guardrails so published builds stay leaner and core stops carrying extension-owned runtime baggage. (#67099) Thanks @vincentkoc.
- QA/Matrix: split Matrix live QA into a source-linked `qa-matrix` runner and keep repo-private `qa-*` surfaces out of packaged and published builds. (#66723) Thanks @gumadeiras.
- Docs/showcase: add a scannable hero, complete section jump links, and a responsive video grid for community examples. (#48493) Thanks @jchopard69.
### Fixes
@@ -115,6 +131,7 @@ Docs: https://docs.openclaw.ai
- Webchat/security: reject remote-host `file://` URLs in the media embedding path. (#67293) Thanks @pgondhi987.
- Dreaming/memory-core: use the ingestion day, not the source file day, for daily recall dedupe so repeat sweeps of the same daily note can increment `dailyCount` across days instead of stalling at `1`. (#67091) Thanks @Bartok9.
- Node-host/tools.exec: let approval binding distinguish known native binaries from mutable shell payload files, while still fail-closing unknown or racy file probes so absolute-path node-host commands like `/usr/bin/whoami` no longer get rejected as unsafe interpreter/runtime commands. (#66731) Thanks @tmimmanuel.
- Codex/gateway: fix gateway crash when the codex-acp subprocess terminates abruptly; an unhandled EPIPE on the child stdin stream now routes through graceful client shutdown, rejecting pending requests instead of propagating as an uncaught exception that crashes the entire gateway daemon and all connected channels. Fixes #67886. (#67947) thanks @openperf
## 2026.4.14

View File

@@ -146,6 +146,7 @@ final class MacNodeModeCoordinator {
OpenClawCanvasA2UICommand.push.rawValue,
OpenClawCanvasA2UICommand.pushJSONL.rawValue,
OpenClawCanvasA2UICommand.reset.rawValue,
MacNodeScreenCommand.snapshot.rawValue,
MacNodeScreenCommand.record.rawValue,
OpenClawSystemCommand.notify.rawValue,
OpenClawSystemCommand.which.rawValue,

View File

@@ -63,6 +63,8 @@ actor MacNodeRuntime {
return try await self.handleCameraInvoke(req)
case OpenClawLocationCommand.get.rawValue:
return try await self.handleLocationInvoke(req)
case MacNodeScreenCommand.snapshot.rawValue:
return try await self.handleScreenSnapshotInvoke(req)
case MacNodeScreenCommand.record.rawValue:
return try await self.handleScreenRecordInvoke(req)
case OpenClawSystemCommand.run.rawValue:
@@ -352,6 +354,34 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleScreenSnapshotInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = (try? Self.decodeParams(MacNodeScreenSnapshotParams.self, from: req.paramsJSON)) ??
MacNodeScreenSnapshotParams()
let services = await self.mainActorServices()
let capturedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
let res = try await services.snapshotScreen(
screenIndex: params.screenIndex,
maxWidth: params.maxWidth,
quality: params.quality,
format: params.format)
struct ScreenSnapshotPayload: Encodable {
var format: String
var base64: String
var width: Int
var height: Int
var screenIndex: Int?
var capturedAtMs: Int64
}
let payload = try Self.encodePayload(ScreenSnapshotPayload(
format: res.format.rawValue,
base64: res.data.base64EncodedString(),
width: res.width,
height: res.height,
screenIndex: params.screenIndex,
capturedAtMs: capturedAtMs))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func mainActorServices() async -> any MacNodeRuntimeMainActorServices {
if let cachedMainActorServices { return cachedMainActorServices }
let services = await self.makeMainActorServices()

View File

@@ -4,6 +4,13 @@ import OpenClawKit
@MainActor
protocol MacNodeRuntimeMainActorServices: Sendable {
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
@@ -21,9 +28,24 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
@MainActor
final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
private let screenSnapshotter = ScreenSnapshotService()
private let screenRecorder = ScreenRecordService()
private let locationService = MacNodeLocationService()
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
try await self.screenSnapshotter.snapshot(
screenIndex: screenIndex,
maxWidth: maxWidth,
quality: quality,
format: format)
}
func recordScreen(
screenIndex: Int?,
durationMs: Int?,

View File

@@ -1,9 +1,18 @@
import Foundation
import OpenClawKit
enum MacNodeScreenCommand: String, Codable {
case snapshot = "screen.snapshot"
case record = "screen.record"
}
struct MacNodeScreenSnapshotParams: Codable, Equatable {
var screenIndex: Int?
var maxWidth: Int?
var quality: Double?
var format: OpenClawScreenSnapshotFormat?
}
struct MacNodeScreenRecordParams: Codable, Equatable {
var screenIndex: Int?
var durationMs: Int?

View File

@@ -0,0 +1,109 @@
import AppKit
import Foundation
import OpenClawKit
@preconcurrency import ScreenCaptureKit
@MainActor
final class ScreenSnapshotService {
enum ScreenSnapshotError: LocalizedError {
case noDisplays
case invalidScreenIndex(Int)
case captureFailed(String)
case encodeFailed(String)
var errorDescription: String? {
switch self {
case .noDisplays:
"No displays available for screen snapshot"
case let .invalidScreenIndex(idx):
"Invalid screen index \(idx)"
case let .captureFailed(message):
message
case let .encodeFailed(message):
message
}
}
}
func snapshot(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
let format = format ?? .jpeg
let normalized = Self.normalize(maxWidth: maxWidth, quality: quality, format: format)
let content = try await SCShareableContent.current
let displays = content.displays.sorted { $0.displayID < $1.displayID }
guard !displays.isEmpty else {
throw ScreenSnapshotError.noDisplays
}
let idx = screenIndex ?? 0
guard idx >= 0, idx < displays.count else {
throw ScreenSnapshotError.invalidScreenIndex(idx)
}
let display = displays[idx]
let filter = SCContentFilter(display: display, excludingWindows: [])
let config = SCStreamConfiguration()
let targetSize = Self.targetSize(
width: display.width,
height: display.height,
maxWidth: normalized.maxWidth)
config.width = targetSize.width
config.height = targetSize.height
config.showsCursor = true
let cgImage: CGImage
do {
cgImage = try await SCScreenshotManager.captureImage(
contentFilter: filter,
configuration: config)
} catch {
throw ScreenSnapshotError.captureFailed(error.localizedDescription)
}
let bitmap = NSBitmapImageRep(cgImage: cgImage)
let data: Data
switch format {
case .png:
guard let encoded = bitmap.representation(using: .png, properties: [:]) else {
throw ScreenSnapshotError.encodeFailed("png encode failed")
}
data = encoded
case .jpeg:
guard let encoded = bitmap.representation(
using: .jpeg,
properties: [.compressionFactor: normalized.quality])
else {
throw ScreenSnapshotError.encodeFailed("jpeg encode failed")
}
data = encoded
}
return (data: data, format: format, width: cgImage.width, height: cgImage.height)
}
private static func normalize(
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat)
-> (maxWidth: Int, quality: Double)
{
let resolvedMaxWidth = maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? (format == .png ? 900 : 1600)
let resolvedQuality = min(1.0, max(0.05, quality ?? 0.72))
return (maxWidth: resolvedMaxWidth, quality: resolvedQuality)
}
private static func targetSize(width: Int, height: Int, maxWidth: Int) -> (width: Int, height: Int) {
guard width > 0, height > 0, width > maxWidth else {
return (width: width, height: height)
}
let scale = Double(maxWidth) / Double(width)
let targetHeight = max(1, Int((Double(height) * scale).rounded()))
return (width: maxWidth, height: targetHeight)
}
}

View File

@@ -78,6 +78,19 @@ struct MacNodeRuntimeTests {
@Test func `handle invoke screen record uses injected services`() async throws {
@MainActor
final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
_ = screenIndex
_ = maxWidth
_ = quality
return (Data("snapshot".utf8), format ?? .jpeg, 640, 360)
}
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
@@ -127,6 +140,94 @@ struct MacNodeRuntimeTests {
#expect(!payload.base64.isEmpty)
}
@Test func `handle invoke screen snapshot uses injected services`() async throws {
@MainActor
final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
var snapshotCalledAtMs: Int64?
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
self.snapshotCalledAtMs = Int64(Date().timeIntervalSince1970 * 1000)
#expect(screenIndex == 0)
#expect(maxWidth == 800)
#expect(quality == 0.5)
return (Data("ok".utf8), format ?? .jpeg, 800, 450)
}
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
fps: Double?,
includeAudio: Bool?,
outPath: String?) async throws -> (path: String, hasAudio: Bool)
{
let url = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-test-screen-record-\(UUID().uuidString).mp4")
try Data("ok".utf8).write(to: url)
return (path: url.path, hasAudio: false)
}
func locationAuthorizationStatus() -> CLAuthorizationStatus {
.authorizedAlways
}
func locationAccuracyAuthorization() -> CLAccuracyAuthorization {
.fullAccuracy
}
func currentLocation(
desiredAccuracy: OpenClawLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
_ = desiredAccuracy
_ = maxAgeMs
_ = timeoutMs
return CLLocation(latitude: 0, longitude: 0)
}
}
let services = await MainActor.run { FakeMainActorServices() }
let runtime = MacNodeRuntime(makeMainActorServices: { services })
let params = MacNodeScreenSnapshotParams(
screenIndex: 0,
maxWidth: 800,
quality: 0.5,
format: .jpeg)
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(
id: "req-screen-snapshot",
command: MacNodeScreenCommand.snapshot.rawValue,
paramsJSON: json))
#expect(response.ok == true)
let payloadJSON = try #require(response.payloadJSON)
struct Payload: Decodable {
var format: String
var base64: String
var width: Int
var height: Int
var capturedAtMs: Int64
}
let payload = try JSONDecoder().decode(Payload.self, from: Data(payloadJSON.utf8))
#expect(payload.format == "jpeg")
#expect(payload.base64 == Data("ok".utf8).base64EncodedString())
#expect(payload.width == 800)
#expect(payload.height == 450)
#expect(payload.capturedAtMs > 0)
let snapshotCalledAtMs = await MainActor.run { services.snapshotCalledAtMs }
#expect(snapshotCalledAtMs != nil)
#expect(payload.capturedAtMs <= snapshotCalledAtMs!)
}
@Test func `handle invoke browser proxy uses injected request`() async {
let runtime = MacNodeRuntime(browserProxyRequest: { paramsJSON in
#expect(paramsJSON?.contains("/tabs") == true)

View File

@@ -444,34 +444,18 @@ private struct ChatComposerTextView: NSViewRepresentable {
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeNSView(context: Context) -> NSScrollView {
let textView = ChatComposerNSTextView()
textView.delegate = context.coordinator
textView.drawsBackground = false
textView.isRichText = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.font = .systemFont(ofSize: 14, weight: .regular)
textView.textContainer?.lineBreakMode = .byWordWrapping
textView.textContainer?.lineFragmentPadding = 0
textView.textContainerInset = NSSize(width: 2, height: 4)
textView.focusRingType = .none
let textView = ChatComposerTextViewFactory.makeConfiguredTextView()
guard let composerTextView = textView as? ChatComposerNSTextView else {
preconditionFailure("ChatComposerTextViewFactory must return ChatComposerNSTextView")
}
composerTextView.delegate = context.coordinator
textView.minSize = .zero
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.autoresizingMask = [.width]
textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
textView.textContainer?.widthTracksTextView = true
textView.string = self.text
textView.onSend = { [weak textView] in
textView?.window?.makeFirstResponder(nil)
composerTextView.string = self.text
composerTextView.onSend = { [weak composerTextView] in
composerTextView?.window?.makeFirstResponder(nil)
self.onSend()
}
textView.onPasteImageAttachment = self.onPasteImageAttachment
composerTextView.onPasteImageAttachment = self.onPasteImageAttachment
let scroll = NSScrollView()
scroll.drawsBackground = false
@@ -522,6 +506,34 @@ private struct ChatComposerTextView: NSViewRepresentable {
}
}
enum ChatComposerTextViewFactory {
// Internal for @testable import coverage of composer text view defaults.
@MainActor
static func makeConfiguredTextView() -> NSTextView {
let textView = ChatComposerNSTextView()
textView.drawsBackground = false
textView.isRichText = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.font = .systemFont(ofSize: 14, weight: .regular)
textView.textContainer?.lineBreakMode = .byWordWrapping
textView.textContainer?.lineFragmentPadding = 0
textView.textContainerInset = NSSize(width: 2, height: 4)
textView.focusRingType = .none
textView.allowsUndo = true
textView.minSize = .zero
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.autoresizingMask = [.width]
textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
textView.textContainer?.widthTracksTextView = true
return textView
}
}
private final class ChatComposerNSTextView: NSTextView {
var onSend: (() -> Void)?
var onPasteImageAttachment: ((_ data: Data, _ fileName: String, _ mimeType: String) -> Void)?

View File

@@ -1,9 +1,34 @@
import Foundation
public enum OpenClawScreenCommand: String, Codable, Sendable {
case snapshot = "screen.snapshot"
case record = "screen.record"
}
public enum OpenClawScreenSnapshotFormat: String, Codable, Sendable {
case jpeg
case png
}
public struct OpenClawScreenSnapshotParams: Codable, Sendable, Equatable {
public var screenIndex: Int?
public var maxWidth: Int?
public var quality: Double?
public var format: OpenClawScreenSnapshotFormat?
public init(
screenIndex: Int? = nil,
maxWidth: Int? = nil,
quality: Double? = nil,
format: OpenClawScreenSnapshotFormat? = nil)
{
self.screenIndex = screenIndex
self.maxWidth = maxWidth
self.quality = quality
self.format = format
}
}
public struct OpenClawScreenRecordParams: Codable, Sendable, Equatable {
public var screenIndex: Int?
public var durationMs: Int?

View File

@@ -0,0 +1,15 @@
#if os(macOS)
import AppKit
import Testing
@testable import OpenClawChatUI
@Suite
@MainActor
struct ChatComposerTextViewTests {
@Test func configuredComposerTextViewEnablesUndo() {
let textView = ChatComposerTextViewFactory.makeConfiguredTextView()
#expect(textView.allowsUndo)
}
}
#endif

View File

@@ -1,2 +1,2 @@
9683f324fae8f455f2b64d7e152a77009941e4c7558521bca2510d8bcf573af9 plugin-sdk-api-baseline.json
097bf226e4e857e9296d0851852a2963c6263d176c4c470452d9a8efd36988e5 plugin-sdk-api-baseline.jsonl
e3df4c13b4dcdc07809775c56eed15c3ab924db191a08fb5a7b48d6f73001966 plugin-sdk-api-baseline.json
2bb30ad45d5b382e92fd6b8a240a47f7679c59f9b524e54420879fadc28264b8 plugin-sdk-api-baseline.jsonl

View File

@@ -120,7 +120,7 @@ can write back through the mounted workspace.
Seed assets live in `qa/`:
- `qa/scenarios/index.md`
- `qa/scenarios/*.md`
- `qa/scenarios/<theme>/*.md`
These are intentionally in git so the QA plan is visible to both humans and the
agent.
@@ -129,6 +129,7 @@ agent.
the source of truth for one test run and should define:
- scenario metadata
- optional category, capability, lane, and risk metadata
- docs and code refs
- optional plugin requirements
- optional gateway config patch
@@ -139,6 +140,10 @@ and cross-cutting. For example, markdown scenarios can combine transport-side
helpers with browser-side helpers that drive the embedded Control UI through the
Gateway `browser.request` seam without adding a special-case runner.
Scenario files should be grouped by product capability rather than source tree
folder. Keep scenario IDs stable when files move; use `docsRefs` and `codeRefs`
for implementation traceability.
The baseline list should stay broad enough to cover:
- DM and channel chat

View File

@@ -89,7 +89,21 @@ Gateway → Client:
```
`server`, `features`, `snapshot`, and `policy` are all required by the schema
(`src/gateway/protocol/schema/frames.ts`). `auth` and `canvasHostUrl` are optional.
(`src/gateway/protocol/schema/frames.ts`). `canvasHostUrl` is optional. `auth`
reports the negotiated role/scopes when available, and includes `deviceToken`
when the gateway issues one.
When no device token is issued, `hello-ok.auth` can still report the negotiated
permissions:
```json
{
"auth": {
"role": "operator",
"scopes": ["operator.read", "operator.write"]
}
}
```
When a device token is issued, `hello-ok` also includes:

View File

@@ -213,7 +213,7 @@ The minimum adoption bar for a new channel is:
4. Mount the runner as `openclaw qa <runner>` instead of registering a competing root command.
Runner plugins should declare `qaRunners` in `openclaw.plugin.json` and export a matching `qaRunnerCliRegistrations` array from `runtime-api.ts`.
Keep `runtime-api.ts` light; lazy CLI and runner execution should stay behind separate entrypoints.
5. Author or adapt markdown scenarios under `qa/scenarios/`.
5. Author or adapt markdown scenarios under the themed `qa/scenarios/` directories.
6. Use the generic scenario helpers for new scenarios.
7. Keep existing compatibility aliases working unless the repo is doing an intentional migration.

View File

@@ -55,7 +55,7 @@ The macOS app presents itself as a node. Common commands:
- Canvas: `canvas.present`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`, `canvas.a2ui.*`
- Camera: `camera.snap`, `camera.clip`
- Screen: `screen.record`
- Screen: `screen.snapshot`, `screen.record`
- System: `system.run`, `system.notify`
The node reports a `permissions` map so agents can decide whats allowed.

View File

@@ -185,7 +185,9 @@ Keep inbound mention handling split in two layers:
- plugin-owned evidence gathering
- shared policy evaluation
Use `openclaw/plugin-sdk/channel-inbound` for the shared layer.
Use `openclaw/plugin-sdk/channel-mention-gating` for mention-policy decisions.
Use `openclaw/plugin-sdk/channel-inbound` only when you need the broader inbound
helper barrel.
Good fit for plugin-local logic:
@@ -255,6 +257,11 @@ bundled channel plugins that already depend on runtime injection:
- `implicitMentionKindWhen`
- `resolveInboundMentionDecision`
If you only need `implicitMentionKindWhen` and
`resolveInboundMentionDecision`, import from
`openclaw/plugin-sdk/channel-mention-gating` to avoid loading unrelated inbound
runtime helpers.
The older `resolveMentionGating*` helpers remain on
`openclaw/plugin-sdk/channel-inbound` as compatibility exports only. New code
should use `resolveInboundMentionDecision({ facts, policy })`.

View File

@@ -88,6 +88,7 @@ explicitly promotes one as public.
| `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter` |
| `plugin-sdk/channel-config-schema` | Channel config schema types |
| `plugin-sdk/telegram-command-config` | Telegram custom-command normalization/validation helpers with bundled-contract fallback |
| `plugin-sdk/command-gating` | Narrow command authorization gate helpers |
| `plugin-sdk/channel-policy` | `resolveChannelGroupRequireMention` |
| `plugin-sdk/channel-lifecycle` | `createAccountStatusSink` |
| `plugin-sdk/inbound-envelope` | Shared inbound route + envelope builder helpers |
@@ -95,6 +96,7 @@ explicitly promotes one as public.
| `plugin-sdk/messaging-targets` | Target parsing/matching helpers |
| `plugin-sdk/outbound-media` | Shared outbound media loading helpers |
| `plugin-sdk/outbound-runtime` | Outbound identity/send delegate helpers |
| `plugin-sdk/poll-runtime` | Narrow poll normalization helpers |
| `plugin-sdk/thread-bindings-runtime` | Thread-binding lifecycle and adapter helpers |
| `plugin-sdk/agent-media-payload` | Legacy agent media payload builder |
| `plugin-sdk/conversation-runtime` | Conversation/thread binding, pairing, and configured-binding helpers |
@@ -108,7 +110,10 @@ explicitly promotes one as public.
| `plugin-sdk/group-access` | Shared group-access decision helpers |
| `plugin-sdk/direct-dm` | Shared direct-DM auth/guard helpers |
| `plugin-sdk/interactive-runtime` | Interactive reply payload normalization/reduction helpers |
| `plugin-sdk/channel-inbound` | Inbound debounce, mention matching, mention-policy helpers, and envelope helpers |
| `plugin-sdk/channel-inbound` | Compatibility barrel for inbound debounce, mention matching, mention-policy helpers, and envelope helpers |
| `plugin-sdk/channel-mention-gating` | Narrow mention-policy helpers without the broader inbound runtime surface |
| `plugin-sdk/channel-location` | Channel location context and formatting helpers |
| `plugin-sdk/channel-logging` | Channel logging helpers for inbound drops and typing/ack failures |
| `plugin-sdk/channel-send-result` | Reply result types |
| `plugin-sdk/channel-actions` | `createMessageToolButtonsSchema`, `createMessageToolCardSchema` |
| `plugin-sdk/channel-targets` | Target parsing/matching helpers |
@@ -166,6 +171,7 @@ explicitly promotes one as public.
| `plugin-sdk/secret-ref-runtime` | Narrow `coerceSecretRef` and SecretRef typing helpers for secret-contract/config parsing |
| `plugin-sdk/security-runtime` | Shared trust, DM gating, external-content, and secret-collection helpers |
| `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers |
| `plugin-sdk/ssrf-dispatcher` | Narrow pinned-dispatcher helpers without the broad infra runtime surface |
| `plugin-sdk/ssrf-runtime` | Pinned-dispatcher, SSRF-guarded fetch, and SSRF policy helpers |
| `plugin-sdk/secret-input` | Secret input parsing helpers |
| `plugin-sdk/webhook-ingress` | Webhook request/target helpers |
@@ -187,6 +193,7 @@ explicitly promotes one as public.
| `plugin-sdk/gateway-runtime` | Gateway client and channel-status patch helpers |
| `plugin-sdk/config-runtime` | Config load/write helpers |
| `plugin-sdk/telegram-command-config` | Telegram command-name/description normalization and duplicate/conflict checks, even when the bundled Telegram contract surface is unavailable |
| `plugin-sdk/text-autolink-runtime` | File-reference autolink detection without the broad text-runtime barrel |
| `plugin-sdk/approval-runtime` | Exec/plugin approval helpers, approval-capability builders, auth/profile helpers, native routing/runtime helpers |
| `plugin-sdk/reply-runtime` | Shared inbound/reply runtime helpers, chunking, dispatch, heartbeat, reply planner |
| `plugin-sdk/reply-dispatch-runtime` | Narrow reply dispatch/finalize helpers |
@@ -211,6 +218,7 @@ explicitly promotes one as public.
| `plugin-sdk/file-lock` | Re-entrant file-lock helpers |
| `plugin-sdk/persistent-dedupe` | Disk-backed dedupe cache helpers |
| `plugin-sdk/acp-runtime` | ACP runtime/session and reply-dispatch helpers |
| `plugin-sdk/acp-binding-resolve-runtime` | Read-only ACP binding resolution without lifecycle startup imports |
| `plugin-sdk/agent-config-primitives` | Narrow agent runtime config-schema primitives |
| `plugin-sdk/boolean-param` | Loose boolean param reader |
| `plugin-sdk/dangerous-name-runtime` | Dangerous-name matching resolution helpers |
@@ -226,6 +234,12 @@ explicitly promotes one as public.
| `plugin-sdk/diagnostic-runtime` | Diagnostic flag and event helpers |
| `plugin-sdk/error-runtime` | Error graph, formatting, shared error classification helpers, `isApprovalNotFoundError` |
| `plugin-sdk/fetch-runtime` | Wrapped fetch, proxy, and pinned lookup helpers |
| `plugin-sdk/runtime-fetch` | Dispatcher-aware runtime fetch without proxy/guarded-fetch imports |
| `plugin-sdk/response-limit-runtime` | Bounded response-body reader without the broad media runtime surface |
| `plugin-sdk/session-binding-runtime` | Current conversation binding state without configured binding routing or pairing stores |
| `plugin-sdk/session-store-runtime` | Session-store read helpers without broad config writes/maintenance imports |
| `plugin-sdk/context-visibility-runtime` | Context visibility resolution and supplemental context filtering without broad config/security imports |
| `plugin-sdk/string-coerce-runtime` | Narrow primitive record/string coercion and normalization helpers without markdown/logging imports |
| `plugin-sdk/host-runtime` | Hostname and SCP host normalization helpers |
| `plugin-sdk/retry-runtime` | Retry config and retry runner helpers |
| `plugin-sdk/agent-runtime` | Agent dir/identity/workspace helpers |

View File

@@ -18,7 +18,7 @@ The desired end state is a generic QA harness that loads powerful scenario defin
## Current State
Primary source of truth now lives in `qa/scenarios/index.md` plus one file per
scenario under `qa/scenarios/*.md`.
scenario under `qa/scenarios/<theme>/*.md`.
Implemented:
@@ -26,7 +26,7 @@ Implemented:
- canonical QA pack metadata
- operator identity
- kickoff mission
- `qa/scenarios/*.md`
- `qa/scenarios/<theme>/*.md`
- one markdown file per scenario
- scenario metadata
- handler bindings
@@ -107,8 +107,8 @@ These categories matter because they drive DSL requirements. A flat list of prom
### Single source of truth
Use `qa/scenarios/index.md` plus `qa/scenarios/*.md` as the authored source of
truth.
Use `qa/scenarios/index.md` plus `qa/scenarios/<theme>/*.md` as the authored
source of truth.
The pack should stay:
@@ -363,7 +363,7 @@ Generated compatibility:
Done.
- added `qa/scenarios/index.md`
- split scenarios into `qa/scenarios/*.md`
- split scenarios into `qa/scenarios/<theme>/*.md`
- added parser for named markdown YAML pack content
- validated with zod
- switched consumers to the parsed pack

View File

@@ -82,7 +82,10 @@ html.dark .nav-tabs-underline {
border-radius: 8px;
background: color-mix(in oklab, rgb(var(--primary)) 4%, transparent);
text-decoration: none;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
transition:
transform 0.16s ease,
border-color 0.16s ease,
background 0.16s ease;
}
.showcase-actions a:first-child {

View File

@@ -60,6 +60,10 @@ third-party plugins see.
- Do not rely on eager global registry seeding or import-time side effects to
make a plugin “available”. Plugin availability should come from manifest
ownership plus targeted activation.
- When core needs plugin-owned static data on a hot path, expose a lightweight
top-level artifact such as `gateway-auth-api.ts`, `message-tool-api.ts`, or a
similarly narrow `*-api.ts`. Reuse the same local helper from the artifact and
the full plugin so fast paths do not drift from runtime behavior.
## Expanding The Boundary

View File

@@ -1527,7 +1527,9 @@ function extractRecentTurns(messages: unknown[]): ActiveRecallRecentTurn[] {
}
const rawText = extractTextContent(typed.content);
const text =
role === "assistant" ? stripRecalledContextNoise(rawText) : stripInjectedActiveMemoryPrefixOnly(rawText);
role === "assistant"
? stripRecalledContextNoise(rawText)
: stripInjectedActiveMemoryPrefixOnly(rawText);
if (!text) {
continue;
}

View File

@@ -2,3 +2,7 @@ export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export {
__testing as blueBubblesConversationBindingTesting,
createBlueBubblesConversationBindingManager,
} from "./src/conversation-bindings.js";

View File

@@ -0,0 +1,46 @@
/** Resolve the correct targetId after a navigation that may trigger a renderer swap. */
export async function resolveTargetIdAfterNavigate(opts: {
oldTargetId: string;
navigatedUrl: string;
listTabs: () => Promise<Array<{ targetId: string; url: string }>>;
retryDelayMs?: number;
}): Promise<string> {
let currentTargetId = opts.oldTargetId;
try {
const pickReplacement = (
tabs: Array<{ targetId: string; url: string }>,
options?: { allowSingleTabFallback?: boolean },
): { targetId: string; shouldRetry: boolean } => {
if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) {
return { targetId: opts.oldTargetId, shouldRetry: false };
}
const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl);
if (byUrl.length === 1) {
return { targetId: byUrl[0]?.targetId ?? opts.oldTargetId, shouldRetry: false };
}
const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId);
if (uniqueReplacement.length === 1) {
return {
targetId: uniqueReplacement[0]?.targetId ?? opts.oldTargetId,
shouldRetry: false,
};
}
if (options?.allowSingleTabFallback && tabs.length === 1) {
return { targetId: tabs[0]?.targetId ?? opts.oldTargetId, shouldRetry: false };
}
return { targetId: opts.oldTargetId, shouldRetry: true };
};
const first = pickReplacement(await opts.listTabs());
currentTargetId = first.targetId;
if (first.shouldRetry) {
await new Promise((r) => setTimeout(r, opts.retryDelayMs ?? 800));
currentTargetId = pickReplacement(await opts.listTabs(), {
allowSingleTabFallback: true,
}).targetId;
}
} catch {
// Best-effort: fall back to pre-navigation targetId.
}
return currentTargetId;
}

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveTargetIdAfterNavigate } from "./agent.snapshot.js";
import { describe, expect, it } from "vitest";
import { resolveTargetIdAfterNavigate } from "./agent.snapshot-target.js";
type Tab = { targetId: string; url: string };
@@ -8,10 +8,6 @@ function staticListTabs(tabs: Tab[]): () => Promise<Tab[]> {
}
describe("resolveTargetIdAfterNavigate", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("returns original targetId when old target still exists (no swap)", async () => {
const result = await resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
@@ -37,6 +33,7 @@ describe("resolveTargetIdAfterNavigate", () => {
const result = await resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://example.com",
retryDelayMs: 0,
listTabs: staticListTabs([
{ targetId: "preexisting-000", url: "https://example.com" },
{ targetId: "fresh-777", url: "https://example.com" },
@@ -47,12 +44,12 @@ describe("resolveTargetIdAfterNavigate", () => {
});
it("retries and resolves targetId when first listTabs has no URL match", async () => {
vi.useFakeTimers();
let calls = 0;
const result$ = resolveTargetIdAfterNavigate({
const result = await resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://delayed.com",
retryDelayMs: 0,
listTabs: async () => {
calls++;
if (calls === 1) {
@@ -62,50 +59,33 @@ describe("resolveTargetIdAfterNavigate", () => {
},
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("delayed-999");
expect(calls).toBe(2);
vi.useRealTimers();
});
it("falls back to original targetId when no match found after retry", async () => {
vi.useFakeTimers();
const result$ = resolveTargetIdAfterNavigate({
const result = await resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://no-match.com",
retryDelayMs: 0,
listTabs: staticListTabs([
{ targetId: "unrelated-1", url: "https://unrelated.com" },
{ targetId: "unrelated-2", url: "https://unrelated2.com" },
]),
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("old-123");
vi.useRealTimers();
});
it("falls back to single remaining tab when no URL match after retry", async () => {
vi.useFakeTimers();
const result$ = resolveTargetIdAfterNavigate({
const result = await resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://single-tab.com",
retryDelayMs: 0,
listTabs: staticListTabs([{ targetId: "only-tab", url: "https://some-other.com" }]),
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("only-tab");
vi.useRealTimers();
});
it("falls back to original targetId when listTabs throws", async () => {
@@ -120,22 +100,16 @@ describe("resolveTargetIdAfterNavigate", () => {
});
it("keeps the old target when multiple replacement candidates still match after retry", async () => {
vi.useFakeTimers();
const result$ = resolveTargetIdAfterNavigate({
const result = await resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://example.com",
retryDelayMs: 0,
listTabs: staticListTabs([
{ targetId: "preexisting-000", url: "https://example.com" },
{ targetId: "fresh-777", url: "https://example.com" },
]),
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("old-123");
vi.useRealTimers();
});
});

View File

@@ -32,6 +32,7 @@ import {
withPlaywrightRouteContext,
withRouteTabContext,
} from "./agent.shared.js";
import { resolveTargetIdAfterNavigate } from "./agent.snapshot-target.js";
import {
resolveSnapshotPlan,
shouldUsePlaywrightForAriaSnapshot,
@@ -172,48 +173,6 @@ async function saveBrowserMediaResponse(params: {
});
}
/** Resolve the correct targetId after a navigation that may trigger a renderer swap. */
export async function resolveTargetIdAfterNavigate(opts: {
oldTargetId: string;
navigatedUrl: string;
listTabs: () => Promise<Array<{ targetId: string; url: string }>>;
}): Promise<string> {
let currentTargetId = opts.oldTargetId;
try {
const pickReplacement = (
tabs: Array<{ targetId: string; url: string }>,
options?: { allowSingleTabFallback?: boolean },
) => {
if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) {
return opts.oldTargetId;
}
const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl);
if (byUrl.length === 1) {
return byUrl[0]?.targetId ?? opts.oldTargetId;
}
const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId);
if (uniqueReplacement.length === 1) {
return uniqueReplacement[0]?.targetId ?? opts.oldTargetId;
}
if (options?.allowSingleTabFallback && tabs.length === 1) {
return tabs[0]?.targetId ?? opts.oldTargetId;
}
return opts.oldTargetId;
};
currentTargetId = pickReplacement(await opts.listTabs());
if (currentTargetId === opts.oldTargetId) {
await new Promise((r) => setTimeout(r, 800));
currentTargetId = pickReplacement(await opts.listTabs(), {
allowSingleTabFallback: true,
});
}
} catch {
// Best-effort: fall back to pre-navigation targetId
}
return currentTargetId;
}
export function registerBrowserAgentSnapshotRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,

View File

@@ -1,176 +0,0 @@
import { describe, expect, it } from "vitest";
import {
installAgentContractHooks,
startServerAndBase,
} from "./server.agent-contract.test-harness.js";
import {
setBrowserControlServerEvaluateEnabled,
setBrowserControlServerProfiles,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-fetch.js";
type ActErrorResponse = {
error?: string;
code?: string;
};
type ActErrorHttpResponse = {
status: number;
body: ActErrorResponse;
};
async function postActAndReadError(base: string, body?: unknown): Promise<ActErrorHttpResponse> {
const realFetch = getBrowserTestFetch();
const response = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return {
status: response.status,
body: (await response.json()) as ActErrorResponse,
};
}
describe("browser control server", () => {
installAgentContractHooks();
const slowTimeoutMs = process.platform === "win32" ? 40_000 : 20_000;
it(
"returns ACT_KIND_REQUIRED when kind is missing",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_KIND_REQUIRED");
expect(response.body.error).toContain("kind is required");
},
slowTimeoutMs,
);
it(
"returns ACT_INVALID_REQUEST for malformed action payloads",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "click",
ref: {},
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
expect(response.body.error).toContain("click requires ref or selector");
},
slowTimeoutMs,
);
it(
"returns ACT_EXISTING_SESSION_UNSUPPORTED for unsupported existing-session actions",
async () => {
setBrowserControlServerProfiles({
openclaw: {
color: "#FF4500",
driver: "existing-session",
},
});
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "batch",
actions: [{ kind: "press", key: "Enter" }],
});
expect(response.status).toBe(501);
expect(response.body.code).toBe("ACT_EXISTING_SESSION_UNSUPPORTED");
expect(response.body.error).toContain("batch");
},
slowTimeoutMs,
);
it(
"returns ACT_TARGET_ID_MISMATCH for batched action targetId overrides",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "batch",
actions: [{ kind: "click", ref: "5", targetId: "other-tab" }],
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(response.body.error).toContain("batched action targetId must match request targetId");
},
slowTimeoutMs,
);
it(
"returns ACT_TARGET_ID_MISMATCH for top-level action targetId overrides",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "click",
ref: "5",
// Intentionally non-string: route-level target selection ignores this,
// while action normalization stringifies it.
targetId: 12345,
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(response.body.error).toContain("action targetId must match request targetId");
},
slowTimeoutMs,
);
it(
"returns ACT_SELECTOR_UNSUPPORTED for selector on unsupported action kinds",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "evaluate",
fn: "() => 1",
selector: "#submit",
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_SELECTOR_UNSUPPORTED");
expect(response.body.error).toContain("'selector' is not supported");
},
slowTimeoutMs,
);
it(
"returns ACT_INVALID_REQUEST for malformed unsupported selector actions before selector gating",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "press",
selector: "#submit",
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
expect(response.body.error).toContain("press requires key");
},
slowTimeoutMs,
);
it(
"returns ACT_EVALUATE_DISABLED when evaluate is blocked by config",
async () => {
setBrowserControlServerEvaluateEnabled(false);
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "evaluate",
fn: "() => 1",
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_EVALUATE_DISABLED");
expect(response.body.error).toContain("browser.evaluateEnabled=false");
},
slowTimeoutMs,
);
});

View File

@@ -0,0 +1,577 @@
import fs from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants.js";
import {
installAgentContractHooks,
postJson,
startServerAndBase,
} from "./server.agent-contract.test-harness.js";
import {
cleanupBrowserControlServerTestContext,
getBrowserControlServerBaseUrl,
getBrowserControlServerTestState,
getCdpMocks,
getPwMocks,
makeResponse,
resetBrowserControlServerTestContext,
setBrowserControlServerEvaluateEnabled,
setBrowserControlServerProfiles,
setBrowserControlServerReachable,
startBrowserControlServerFromConfig,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-fetch.js";
type ActErrorResponse = {
error?: string;
code?: string;
};
type ActErrorHttpResponse = {
status: number;
body: ActErrorResponse;
};
async function postActAndReadError(base: string, body?: unknown): Promise<ActErrorHttpResponse> {
const realFetch = getBrowserTestFetch();
const response = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return {
status: response.status,
body: (await response.json()) as ActErrorResponse,
};
}
const state = getBrowserControlServerTestState();
const cdpMocks = getCdpMocks();
const pwMocks = getPwMocks();
describe("browser control server", () => {
installAgentContractHooks();
const slowTimeoutMs = process.platform === "win32" ? 40_000 : 20_000;
it(
"returns ACT_KIND_REQUIRED when kind is missing",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_KIND_REQUIRED");
expect(response.body.error).toContain("kind is required");
},
slowTimeoutMs,
);
it(
"returns ACT_INVALID_REQUEST for malformed action payloads",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "click",
ref: {},
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
expect(response.body.error).toContain("click requires ref or selector");
},
slowTimeoutMs,
);
it(
"returns ACT_EXISTING_SESSION_UNSUPPORTED for unsupported existing-session actions",
async () => {
setBrowserControlServerProfiles({
openclaw: {
color: "#FF4500",
driver: "existing-session",
},
});
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "batch",
actions: [{ kind: "press", key: "Enter" }],
});
expect(response.status).toBe(501);
expect(response.body.code).toBe("ACT_EXISTING_SESSION_UNSUPPORTED");
expect(response.body.error).toContain("batch");
},
slowTimeoutMs,
);
it(
"returns ACT_TARGET_ID_MISMATCH for batched action targetId overrides",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "batch",
actions: [{ kind: "click", ref: "5", targetId: "other-tab" }],
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(response.body.error).toContain("batched action targetId must match request targetId");
},
slowTimeoutMs,
);
it(
"returns ACT_TARGET_ID_MISMATCH for top-level action targetId overrides",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "click",
ref: "5",
// Intentionally non-string: route-level target selection ignores this,
// while action normalization stringifies it.
targetId: 12345,
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(response.body.error).toContain("action targetId must match request targetId");
},
slowTimeoutMs,
);
it(
"returns ACT_SELECTOR_UNSUPPORTED for selector on unsupported action kinds",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "evaluate",
fn: "() => 1",
selector: "#submit",
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_SELECTOR_UNSUPPORTED");
expect(response.body.error).toContain("'selector' is not supported");
},
slowTimeoutMs,
);
it(
"returns ACT_INVALID_REQUEST for malformed unsupported selector actions before selector gating",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "press",
selector: "#submit",
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
expect(response.body.error).toContain("press requires key");
},
slowTimeoutMs,
);
it(
"returns ACT_EVALUATE_DISABLED when evaluate is blocked by config",
async () => {
setBrowserControlServerEvaluateEnabled(false);
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "evaluate",
fn: "() => 1",
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_EVALUATE_DISABLED");
expect(response.body.error).toContain("browser.evaluateEnabled=false");
},
slowTimeoutMs,
);
it("agent contract: snapshot endpoints", async () => {
const base = await startServerAndBase();
const realFetch = getBrowserTestFetch();
const snapAria = (await realFetch(`${base}/snapshot?format=aria&limit=1`).then((r) =>
r.json(),
)) as { ok: boolean; format?: string };
expect(snapAria.ok).toBe(true);
expect(snapAria.format).toBe("aria");
expect(cdpMocks.snapshotAria).toHaveBeenCalledWith({
wsUrl: "ws://127.0.0.1/devtools/page/abcd1234",
limit: 1,
});
const snapAi = (await realFetch(`${base}/snapshot?format=ai`).then((r) => r.json())) as {
ok: boolean;
format?: string;
};
expect(snapAi.ok).toBe(true);
expect(snapAi.format).toBe("ai");
expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
const snapAiZero = (await realFetch(`${base}/snapshot?format=ai&maxChars=0`).then((r) =>
r.json(),
)) as { ok: boolean; format?: string };
expect(snapAiZero.ok).toBe(true);
expect(snapAiZero.format).toBe("ai");
const [lastCall] = pwMocks.snapshotAiViaPlaywright.mock.calls.at(-1) ?? [];
expect(lastCall).toEqual({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
});
it("agent contract: navigation + common act commands", async () => {
const base = await startServerAndBase();
const realFetch = getBrowserTestFetch();
const nav = await postJson<{ ok: boolean; targetId?: string }>(`${base}/navigate`, {
url: "https://example.com",
});
expect(nav.ok).toBe(true);
expect(typeof nav.targetId).toBe("string");
expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
url: "https://example.com",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const click = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "click",
ref: "1",
button: "left",
modifiers: ["Shift"],
});
expect(click.ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
button: "left",
modifiers: ["Shift"],
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [clickArgs] = pwMocks.clickViaPlaywright.mock.calls[0] ?? [];
expect((clickArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
const clickSelector = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "click", selector: "button.save" }),
});
expect(clickSelector.status).toBe(200);
expect(((await clickSelector.json()) as { ok?: boolean }).ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
selector: "button.save",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [clickSelectorArgs] = pwMocks.clickViaPlaywright.mock.calls[1] ?? [];
expect((clickSelectorArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
const type = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "type",
ref: "1",
text: "",
});
expect(type.ok).toBe(true);
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
text: "",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [typeArgs] = pwMocks.typeViaPlaywright.mock.calls[0] ?? [];
expect((typeArgs as { submit?: boolean }).submit).toBeUndefined();
expect((typeArgs as { slowly?: boolean }).slowly).toBeUndefined();
const press = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "press",
key: "Enter",
});
expect(press.ok).toBe(true);
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
key: "Enter",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [pressArgs] = pwMocks.pressKeyViaPlaywright.mock.calls[0] ?? [];
expect((pressArgs as { delayMs?: number }).delayMs).toBeUndefined();
const hover = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "hover",
ref: "2",
});
expect(hover.ok).toBe(true);
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
}),
);
const [hoverArgs] = pwMocks.hoverViaPlaywright.mock.calls[0] ?? [];
expect((hoverArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
const scroll = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "scrollIntoView",
ref: "2",
});
expect(scroll.ok).toBe(true);
expect(pwMocks.scrollIntoViewViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
}),
);
const [scrollArgs] = pwMocks.scrollIntoViewViaPlaywright.mock.calls[0] ?? [];
expect((scrollArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
const drag = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "drag",
startRef: "3",
endRef: "4",
});
expect(drag.ok).toBe(true);
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
startRef: "3",
endRef: "4",
}),
);
const [dragArgs] = pwMocks.dragViaPlaywright.mock.calls[0] ?? [];
expect((dragArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
});
it("POST /tabs/open?profile=unknown returns 404", async () => {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const result = await realFetch(`${base}/tabs/open?profile=unknown`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
});
expect(result.status).toBe(404);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("not found");
});
it("POST /tabs/open returns 400 for invalid URLs", async () => {
setBrowserControlServerReachable(true);
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const result = await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "not a url" }),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("Invalid URL:");
});
});
describe("profile CRUD endpoints", () => {
beforeEach(async () => {
await resetBrowserControlServerTestContext();
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = url;
if (u.includes("/json/list")) {
return makeResponse([]);
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
await cleanupBrowserControlServerTestContext();
});
it("validates profile create/delete endpoints", async () => {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const createMissingName = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(createMissingName.status).toBe(400);
const createMissingNameBody = (await createMissingName.json()) as { error: string };
expect(createMissingNameBody.error).toContain("name is required");
const createInvalidName = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Invalid Name!" }),
});
expect(createInvalidName.status).toBe(400);
const createInvalidNameBody = (await createInvalidName.json()) as { error: string };
expect(createInvalidNameBody.error).toContain("invalid profile name");
const createDuplicate = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "openclaw" }),
});
expect(createDuplicate.status).toBe(409);
const createDuplicateBody = (await createDuplicate.json()) as { error: string };
expect(createDuplicateBody.error).toContain("already exists");
const createRemote = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "remote", cdpUrl: "http://10.0.0.42:9222" }),
});
expect(createRemote.status).toBe(200);
const createRemoteBody = (await createRemote.json()) as {
profile?: string;
cdpUrl?: string;
isRemote?: boolean;
};
expect(createRemoteBody.profile).toBe("remote");
expect(createRemoteBody.cdpUrl).toBe("http://10.0.0.42:9222");
expect(createRemoteBody.isRemote).toBe(true);
const createBadRemote = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "badremote", cdpUrl: "ftp://bad" }),
});
expect(createBadRemote.status).toBe(400);
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
expect(createBadRemoteBody.error).toContain("cdpUrl");
const createClawd = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "legacyclawd", driver: "clawd" }),
});
expect(createClawd.status).toBe(200);
const createClawdBody = (await createClawd.json()) as {
profile?: string;
transport?: string;
cdpPort?: number | null;
userDataDir?: string | null;
};
expect(createClawdBody.profile).toBe("legacyclawd");
expect(createClawdBody.transport).toBe("cdp");
expect(createClawdBody.cdpPort).toBeTypeOf("number");
expect(createClawdBody.userDataDir).toBeNull();
const explicitUserDataDir = "/tmp/openclaw-brave-profile";
await fs.promises.mkdir(explicitUserDataDir, { recursive: true });
const createExistingSession = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "brave-live",
driver: "existing-session",
userDataDir: explicitUserDataDir,
}),
});
expect(createExistingSession.status).toBe(200);
const createExistingSessionBody = (await createExistingSession.json()) as {
profile?: string;
transport?: string;
userDataDir?: string | null;
};
expect(createExistingSessionBody.profile).toBe("brave-live");
expect(createExistingSessionBody.transport).toBe("chrome-mcp");
expect(createExistingSessionBody.userDataDir).toBe(explicitUserDataDir);
const createBadExistingSession = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "bad-live",
userDataDir: explicitUserDataDir,
}),
});
expect(createBadExistingSession.status).toBe(400);
const createBadExistingSessionBody = (await createBadExistingSession.json()) as {
error: string;
};
expect(createBadExistingSessionBody.error).toContain("driver=existing-session is required");
const createLegacyDriver = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "legacy", driver: "extension" }),
});
expect(createLegacyDriver.status).toBe(400);
const createLegacyDriverBody = (await createLegacyDriver.json()) as { error: string };
expect(createLegacyDriverBody.error).toContain('unsupported profile driver "extension"');
const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, {
method: "DELETE",
});
expect(deleteMissing.status).toBe(404);
const deleteMissingBody = (await deleteMissing.json()) as { error: string };
expect(deleteMissingBody.error).toContain("not found");
const deleteDefault = await realFetch(`${base}/profiles/openclaw`, {
method: "DELETE",
});
expect(deleteDefault.status).toBe(400);
const deleteDefaultBody = (await deleteDefault.json()) as { error: string };
expect(deleteDefaultBody.error).toContain("cannot delete the default profile");
const deleteInvalid = await realFetch(`${base}/profiles/Invalid-Name!`, {
method: "DELETE",
});
expect(deleteInvalid.status).toBe(400);
const deleteInvalidBody = (await deleteInvalid.json()) as { error: string };
expect(deleteInvalidBody.error).toContain("invalid profile name");
});
});

View File

@@ -1,217 +0,0 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants.js";
import {
installAgentContractHooks,
postJson,
startServerAndBase,
} from "./server.agent-contract.test-harness.js";
import {
getBrowserControlServerTestState,
getCdpMocks,
getPwMocks,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-fetch.js";
const state = getBrowserControlServerTestState();
const cdpMocks = getCdpMocks();
const pwMocks = getPwMocks();
describe("browser control server", () => {
installAgentContractHooks();
it("agent contract: snapshot endpoints", async () => {
const base = await startServerAndBase();
const realFetch = getBrowserTestFetch();
const snapAria = (await realFetch(`${base}/snapshot?format=aria&limit=1`).then((r) =>
r.json(),
)) as { ok: boolean; format?: string };
expect(snapAria.ok).toBe(true);
expect(snapAria.format).toBe("aria");
expect(cdpMocks.snapshotAria).toHaveBeenCalledWith({
wsUrl: "ws://127.0.0.1/devtools/page/abcd1234",
limit: 1,
});
const snapAi = (await realFetch(`${base}/snapshot?format=ai`).then((r) => r.json())) as {
ok: boolean;
format?: string;
};
expect(snapAi.ok).toBe(true);
expect(snapAi.format).toBe("ai");
expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
const snapAiZero = (await realFetch(`${base}/snapshot?format=ai&maxChars=0`).then((r) =>
r.json(),
)) as { ok: boolean; format?: string };
expect(snapAiZero.ok).toBe(true);
expect(snapAiZero.format).toBe("ai");
const [lastCall] = pwMocks.snapshotAiViaPlaywright.mock.calls.at(-1) ?? [];
expect(lastCall).toEqual({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
});
it("agent contract: navigation + common act commands", async () => {
const base = await startServerAndBase();
const realFetch = getBrowserTestFetch();
const nav = await postJson<{ ok: boolean; targetId?: string }>(`${base}/navigate`, {
url: "https://example.com",
});
expect(nav.ok).toBe(true);
expect(typeof nav.targetId).toBe("string");
expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
url: "https://example.com",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const click = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "click",
ref: "1",
button: "left",
modifiers: ["Shift"],
});
expect(click.ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
button: "left",
modifiers: ["Shift"],
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [clickArgs] = pwMocks.clickViaPlaywright.mock.calls[0] ?? [];
expect((clickArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
const clickSelector = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "click", selector: "button.save" }),
});
expect(clickSelector.status).toBe(200);
expect(((await clickSelector.json()) as { ok?: boolean }).ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
selector: "button.save",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [clickSelectorArgs] = pwMocks.clickViaPlaywright.mock.calls[1] ?? [];
expect((clickSelectorArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
const type = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "type",
ref: "1",
text: "",
});
expect(type.ok).toBe(true);
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
text: "",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [typeArgs] = pwMocks.typeViaPlaywright.mock.calls[0] ?? [];
expect((typeArgs as { submit?: boolean }).submit).toBeUndefined();
expect((typeArgs as { slowly?: boolean }).slowly).toBeUndefined();
const press = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "press",
key: "Enter",
});
expect(press.ok).toBe(true);
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
key: "Enter",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [pressArgs] = pwMocks.pressKeyViaPlaywright.mock.calls[0] ?? [];
expect((pressArgs as { delayMs?: number }).delayMs).toBeUndefined();
const hover = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "hover",
ref: "2",
});
expect(hover.ok).toBe(true);
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
}),
);
const [hoverArgs] = pwMocks.hoverViaPlaywright.mock.calls[0] ?? [];
expect((hoverArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
const scroll = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "scrollIntoView",
ref: "2",
});
expect(scroll.ok).toBe(true);
expect(pwMocks.scrollIntoViewViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
}),
);
const [scrollArgs] = pwMocks.scrollIntoViewViaPlaywright.mock.calls[0] ?? [];
expect((scrollArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
const drag = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "drag",
startRef: "3",
endRef: "4",
});
expect(drag.ok).toBe(true);
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
startRef: "3",
endRef: "4",
}),
);
const [dragArgs] = pwMocks.dragViaPlaywright.mock.calls[0] ?? [];
expect((dragArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
});
});

View File

@@ -348,7 +348,6 @@ async function loadBrowserServerModule(): Promise<BrowserServerModule> {
if (browserServerModule) {
return browserServerModule;
}
vi.resetModules();
browserServerModule = await import("../server.js");
return browserServerModule;
}
@@ -484,7 +483,6 @@ export async function startBrowserControlServerFromConfig() {
export async function stopBrowserControlServer(): Promise<void> {
const server = browserServerModule;
browserServerModule = null;
if (!server) {
return;
}

View File

@@ -1,207 +0,0 @@
import fs from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupBrowserControlServerTestContext,
getBrowserControlServerBaseUrl,
installBrowserControlServerHooks,
makeResponse,
resetBrowserControlServerTestContext,
setBrowserControlServerReachable,
startBrowserControlServerFromConfig,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-fetch.js";
describe("browser control server", () => {
installBrowserControlServerHooks();
it("POST /tabs/open?profile=unknown returns 404", async () => {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const result = await realFetch(`${base}/tabs/open?profile=unknown`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
});
expect(result.status).toBe(404);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("not found");
});
it("POST /tabs/open returns 400 for invalid URLs", async () => {
setBrowserControlServerReachable(true);
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const result = await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "not a url" }),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("Invalid URL:");
});
});
describe("profile CRUD endpoints", () => {
beforeEach(async () => {
await resetBrowserControlServerTestContext();
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = url;
if (u.includes("/json/list")) {
return makeResponse([]);
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
await cleanupBrowserControlServerTestContext();
});
it("validates profile create/delete endpoints", async () => {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const createMissingName = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(createMissingName.status).toBe(400);
const createMissingNameBody = (await createMissingName.json()) as { error: string };
expect(createMissingNameBody.error).toContain("name is required");
const createInvalidName = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Invalid Name!" }),
});
expect(createInvalidName.status).toBe(400);
const createInvalidNameBody = (await createInvalidName.json()) as { error: string };
expect(createInvalidNameBody.error).toContain("invalid profile name");
const createDuplicate = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "openclaw" }),
});
expect(createDuplicate.status).toBe(409);
const createDuplicateBody = (await createDuplicate.json()) as { error: string };
expect(createDuplicateBody.error).toContain("already exists");
const createRemote = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "remote", cdpUrl: "http://10.0.0.42:9222" }),
});
expect(createRemote.status).toBe(200);
const createRemoteBody = (await createRemote.json()) as {
profile?: string;
cdpUrl?: string;
isRemote?: boolean;
};
expect(createRemoteBody.profile).toBe("remote");
expect(createRemoteBody.cdpUrl).toBe("http://10.0.0.42:9222");
expect(createRemoteBody.isRemote).toBe(true);
const createBadRemote = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "badremote", cdpUrl: "ftp://bad" }),
});
expect(createBadRemote.status).toBe(400);
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
expect(createBadRemoteBody.error).toContain("cdpUrl");
const createClawd = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "legacyclawd", driver: "clawd" }),
});
expect(createClawd.status).toBe(200);
const createClawdBody = (await createClawd.json()) as {
profile?: string;
transport?: string;
cdpPort?: number | null;
userDataDir?: string | null;
};
expect(createClawdBody.profile).toBe("legacyclawd");
expect(createClawdBody.transport).toBe("cdp");
expect(createClawdBody.cdpPort).toBeTypeOf("number");
expect(createClawdBody.userDataDir).toBeNull();
const explicitUserDataDir = "/tmp/openclaw-brave-profile";
await fs.promises.mkdir(explicitUserDataDir, { recursive: true });
const createExistingSession = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "brave-live",
driver: "existing-session",
userDataDir: explicitUserDataDir,
}),
});
expect(createExistingSession.status).toBe(200);
const createExistingSessionBody = (await createExistingSession.json()) as {
profile?: string;
transport?: string;
userDataDir?: string | null;
};
expect(createExistingSessionBody.profile).toBe("brave-live");
expect(createExistingSessionBody.transport).toBe("chrome-mcp");
expect(createExistingSessionBody.userDataDir).toBe(explicitUserDataDir);
const createBadExistingSession = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "bad-live",
userDataDir: explicitUserDataDir,
}),
});
expect(createBadExistingSession.status).toBe(400);
const createBadExistingSessionBody = (await createBadExistingSession.json()) as {
error: string;
};
expect(createBadExistingSessionBody.error).toContain("driver=existing-session is required");
const createLegacyDriver = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "legacy", driver: "extension" }),
});
expect(createLegacyDriver.status).toBe(400);
const createLegacyDriverBody = (await createLegacyDriver.json()) as { error: string };
expect(createLegacyDriverBody.error).toContain('unsupported profile driver "extension"');
const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, {
method: "DELETE",
});
expect(deleteMissing.status).toBe(404);
const deleteMissingBody = (await deleteMissing.json()) as { error: string };
expect(deleteMissingBody.error).toContain("not found");
const deleteDefault = await realFetch(`${base}/profiles/openclaw`, {
method: "DELETE",
});
expect(deleteDefault.status).toBe(400);
const deleteDefaultBody = (await deleteDefault.json()) as { error: string };
expect(deleteDefaultBody.error).toContain("cannot delete the default profile");
const deleteInvalid = await realFetch(`${base}/profiles/Invalid-Name!`, {
method: "DELETE",
});
expect(deleteInvalid.status).toBe(400);
const deleteInvalidBody = (await deleteInvalid.json()) as { error: string };
expect(deleteInvalidBody.error).toContain("invalid profile name");
});
});

View File

@@ -199,6 +199,39 @@ describe("CodexAppServerClient", () => {
expect(process.kill).toHaveBeenCalledWith("SIGKILL");
expect(process.unref).toHaveBeenCalledTimes(1);
});
it("handles stdin write errors without crashing the process", async () => {
const harness = createClientHarness();
clients.push(harness.client);
// Start a pending request so we can verify it gets properly rejected.
const pending = harness.client.request("test/method");
// Simulate the child process closing its pipe — a write to the now-dead
// stdin emits an asynchronous EPIPE error on the stream.
harness.process.stdin.destroy(Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
// The pending request must be rejected with the pipe error rather than
// an unhandled exception tearing down the gateway.
await expect(pending).rejects.toThrow("write EPIPE");
// Subsequent requests are rejected immediately (client is closed).
await expect(harness.client.request("another/method")).rejects.toThrow(
"codex app-server client is closed",
);
});
it("does not write to stdin after the child process exits", async () => {
const harness = createClientHarness();
clients.push(harness.client);
// Simulate the child process exiting.
harness.process.emit("exit", 1, null);
// A notification after exit must not attempt a write.
harness.client.notify("late/event", { data: "ignored" });
expect(harness.writes).toHaveLength(0);
});
it("reads the Codex version from the app-server user agent", () => {
expect(readCodexVersionFromUserAgent("Codex Desktop/0.118.0")).toBe("0.118.0");
expect(readCodexVersionFromUserAgent("openclaw/0.118.0 (macOS; test)")).toBe("0.118.0");

View File

@@ -74,6 +74,13 @@ export class CodexAppServerClient {
),
);
});
// Guard against unhandled EPIPE / write-after-close errors on the stdin
// stream. When the child process terminates abruptly the pipe can break
// before the "exit" event fires, so a pending writeMessage() produces an
// asynchronous error on stdin that would otherwise crash the gateway.
child.stdin.on?.("error", (error) =>
this.closeWithError(error instanceof Error ? error : new Error(String(error))),
);
}
static start(options?: Partial<CodexAppServerStartOptions>): CodexAppServerClient {
@@ -212,6 +219,9 @@ export class CodexAppServerClient {
}
private writeMessage(message: RpcRequest | RpcResponse): void {
if (this.closed) {
return;
}
this.child.stdin.write(`${JSON.stringify(message)}\n`);
}
@@ -300,7 +310,9 @@ export class CodexAppServerClient {
return;
}
this.closed = true;
this.lines.close();
this.rejectPendingRequests(error);
closeCodexAppServerTransport(this.child);
}
private rejectPendingRequests(error: Error): void {

View File

@@ -4,6 +4,7 @@ export type CodexAppServerTransport = {
end?: () => unknown;
destroy?: () => unknown;
unref?: () => unknown;
on?: (event: "error", listener: (error: Error) => void) => unknown;
};
stdout: NodeJS.ReadableStream & {
destroy?: () => unknown;

View File

@@ -1,4 +1,5 @@
export { createThreadBindingManager } from "./src/monitor/thread-bindings.manager.js";
export { __testing as discordThreadBindingTesting } from "./src/monitor/thread-bindings.manager.js";
export {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,

View File

@@ -0,0 +1,121 @@
import type { MusicGenerationProvider } from "openclaw/plugin-sdk/music-generation";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import type {
VideoGenerationProvider,
VideoGenerationProviderConfiguredContext,
} from "openclaw/plugin-sdk/video-generation";
export const DEFAULT_GOOGLE_MUSIC_MODEL = "lyria-3-clip-preview";
export const GOOGLE_PRO_MUSIC_MODEL = "lyria-3-pro-preview";
export const GOOGLE_MAX_INPUT_IMAGES = 10;
export const DEFAULT_GOOGLE_VIDEO_MODEL = "veo-3.1-fast-generate-preview";
export const GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS = [4, 6, 8] as const;
export const GOOGLE_VIDEO_MIN_DURATION_SECONDS = GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS[0];
export const GOOGLE_VIDEO_MAX_DURATION_SECONDS =
GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS[GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS.length - 1];
function isGoogleProviderConfigured(
ctx: { agentDir?: string } | VideoGenerationProviderConfiguredContext,
): boolean {
return isProviderApiKeyConfigured({
provider: "google",
agentDir: ctx.agentDir,
});
}
export function createGoogleMusicGenerationProviderMetadata(): Omit<
MusicGenerationProvider,
"generateMusic"
> {
return {
id: "google",
label: "Google",
defaultModel: DEFAULT_GOOGLE_MUSIC_MODEL,
models: [DEFAULT_GOOGLE_MUSIC_MODEL, GOOGLE_PRO_MUSIC_MODEL],
isConfigured: isGoogleProviderConfigured,
capabilities: {
generate: {
maxTracks: 1,
supportsLyrics: true,
supportsInstrumental: true,
supportsFormat: true,
supportedFormatsByModel: {
[DEFAULT_GOOGLE_MUSIC_MODEL]: ["mp3"],
[GOOGLE_PRO_MUSIC_MODEL]: ["mp3", "wav"],
},
},
edit: {
enabled: true,
maxTracks: 1,
maxInputImages: GOOGLE_MAX_INPUT_IMAGES,
supportsLyrics: true,
supportsInstrumental: true,
supportsFormat: true,
supportedFormatsByModel: {
[DEFAULT_GOOGLE_MUSIC_MODEL]: ["mp3"],
[GOOGLE_PRO_MUSIC_MODEL]: ["mp3", "wav"],
},
},
},
};
}
export function createGoogleVideoGenerationProviderMetadata(): Omit<
VideoGenerationProvider,
"generateVideo"
> {
return {
id: "google",
label: "Google",
defaultModel: DEFAULT_GOOGLE_VIDEO_MODEL,
models: [
DEFAULT_GOOGLE_VIDEO_MODEL,
"veo-3.1-generate-preview",
"veo-3.1-lite-generate-preview",
"veo-3.0-fast-generate-001",
"veo-3.0-generate-001",
"veo-2.0-generate-001",
],
isConfigured: isGoogleProviderConfigured,
capabilities: {
generate: {
maxVideos: 1,
maxDurationSeconds: GOOGLE_VIDEO_MAX_DURATION_SECONDS,
supportedDurationSeconds: [...GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS],
aspectRatios: ["16:9", "9:16"],
resolutions: ["720P", "1080P"],
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: true,
supportsAudio: true,
},
imageToVideo: {
enabled: true,
maxVideos: 1,
maxInputImages: 1,
maxDurationSeconds: GOOGLE_VIDEO_MAX_DURATION_SECONDS,
supportedDurationSeconds: [...GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS],
aspectRatios: ["16:9", "9:16"],
resolutions: ["720P", "1080P"],
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: true,
supportsAudio: true,
},
videoToVideo: {
enabled: true,
maxVideos: 1,
maxInputVideos: 1,
maxDurationSeconds: GOOGLE_VIDEO_MAX_DURATION_SECONDS,
supportedDurationSeconds: [...GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS],
aspectRatios: ["16:9", "9:16"],
resolutions: ["720P", "1080P"],
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: true,
supportsAudio: true,
},
},
};
}

View File

@@ -1,17 +1,23 @@
import type { ImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
import type { MediaUnderstandingProvider } from "openclaw/plugin-sdk/media-understanding";
import type { MusicGenerationProvider } from "openclaw/plugin-sdk/music-generation";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import type { VideoGenerationProvider } from "openclaw/plugin-sdk/video-generation";
import { buildGoogleGeminiCliBackend } from "./cli-backend.js";
import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
import {
createGoogleMusicGenerationProviderMetadata,
createGoogleVideoGenerationProviderMetadata,
} from "./generation-provider-metadata.js";
import { geminiMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
import { buildGoogleMusicGenerationProvider } from "./music-generation-provider.js";
import { registerGoogleProvider } from "./provider-registration.js";
import { buildGoogleSpeechProvider } from "./speech-provider.js";
import { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js";
import { buildGoogleVideoGenerationProvider } from "./video-generation-provider.js";
let googleImageGenerationProviderPromise: Promise<ImageGenerationProvider> | null = null;
let googleMediaUnderstandingProviderPromise: Promise<MediaUnderstandingProvider> | null = null;
let googleMusicGenerationProviderPromise: Promise<MusicGenerationProvider> | null = null;
let googleVideoGenerationProviderPromise: Promise<VideoGenerationProvider> | null = null;
type GoogleMediaUnderstandingProvider = Required<
Pick<
@@ -38,6 +44,24 @@ async function loadGoogleMediaUnderstandingProvider(): Promise<MediaUnderstandin
return await googleMediaUnderstandingProviderPromise;
}
async function loadGoogleMusicGenerationProvider(): Promise<MusicGenerationProvider> {
if (!googleMusicGenerationProviderPromise) {
googleMusicGenerationProviderPromise = import("./music-generation-provider.js").then((mod) =>
mod.buildGoogleMusicGenerationProvider(),
);
}
return await googleMusicGenerationProviderPromise;
}
async function loadGoogleVideoGenerationProvider(): Promise<VideoGenerationProvider> {
if (!googleVideoGenerationProviderPromise) {
googleVideoGenerationProviderPromise = import("./video-generation-provider.js").then((mod) =>
mod.buildGoogleVideoGenerationProvider(),
);
}
return await googleVideoGenerationProviderPromise;
}
async function loadGoogleRequiredMediaUnderstandingProvider(): Promise<GoogleMediaUnderstandingProvider> {
const provider = await loadGoogleMediaUnderstandingProvider();
if (
@@ -104,6 +128,22 @@ function createLazyGoogleMediaUnderstandingProvider(): MediaUnderstandingProvide
};
}
function createLazyGoogleMusicGenerationProvider(): MusicGenerationProvider {
return {
...createGoogleMusicGenerationProviderMetadata(),
generateMusic: async (...args) =>
await (await loadGoogleMusicGenerationProvider()).generateMusic(...args),
};
}
function createLazyGoogleVideoGenerationProvider(): VideoGenerationProvider {
return {
...createGoogleVideoGenerationProviderMetadata(),
generateVideo: async (...args) =>
await (await loadGoogleVideoGenerationProvider()).generateVideo(...args),
};
}
export default definePluginEntry({
id: "google",
name: "Google Plugin",
@@ -115,9 +155,9 @@ export default definePluginEntry({
api.registerMemoryEmbeddingProvider(geminiMemoryEmbeddingProviderAdapter);
api.registerImageGenerationProvider(createLazyGoogleImageGenerationProvider());
api.registerMediaUnderstandingProvider(createLazyGoogleMediaUnderstandingProvider());
api.registerMusicGenerationProvider(buildGoogleMusicGenerationProvider());
api.registerMusicGenerationProvider(createLazyGoogleMusicGenerationProvider());
api.registerSpeechProvider(buildGoogleSpeechProvider());
api.registerVideoGenerationProvider(buildGoogleVideoGenerationProvider());
api.registerVideoGenerationProvider(createLazyGoogleVideoGenerationProvider());
api.registerWebSearchProvider(createGeminiWebSearchProvider());
},
});

View File

@@ -5,15 +5,17 @@ import type {
MusicGenerationProvider,
MusicGenerationRequest,
} from "openclaw/plugin-sdk/music-generation";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeGoogleApiBaseUrl } from "./api.js";
import {
createGoogleMusicGenerationProviderMetadata,
DEFAULT_GOOGLE_MUSIC_MODEL,
GOOGLE_MAX_INPUT_IMAGES,
GOOGLE_PRO_MUSIC_MODEL,
} from "./generation-provider-metadata.js";
const DEFAULT_GOOGLE_MUSIC_MODEL = "lyria-3-clip-preview";
const GOOGLE_PRO_MUSIC_MODEL = "lyria-3-pro-preview";
const DEFAULT_TIMEOUT_MS = 180_000;
const GOOGLE_MAX_INPUT_IMAGES = 10;
type GoogleInlineDataPart = {
mimeType?: string;
@@ -99,39 +101,7 @@ function extractTracks(params: { payload: GoogleGenerateMusicResponse; model: st
export function buildGoogleMusicGenerationProvider(): MusicGenerationProvider {
return {
id: "google",
label: "Google",
defaultModel: DEFAULT_GOOGLE_MUSIC_MODEL,
models: [DEFAULT_GOOGLE_MUSIC_MODEL, GOOGLE_PRO_MUSIC_MODEL],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "google",
agentDir,
}),
capabilities: {
generate: {
maxTracks: 1,
supportsLyrics: true,
supportsInstrumental: true,
supportsFormat: true,
supportedFormatsByModel: {
[DEFAULT_GOOGLE_MUSIC_MODEL]: ["mp3"],
[GOOGLE_PRO_MUSIC_MODEL]: ["mp3", "wav"],
},
},
edit: {
enabled: true,
maxTracks: 1,
maxInputImages: GOOGLE_MAX_INPUT_IMAGES,
supportsLyrics: true,
supportsInstrumental: true,
supportsFormat: true,
supportedFormatsByModel: {
[DEFAULT_GOOGLE_MUSIC_MODEL]: ["mp3"],
[GOOGLE_PRO_MUSIC_MODEL]: ["mp3", "wav"],
},
},
},
...createGoogleMusicGenerationProviderMetadata(),
async generateMusic(req) {
if ((req.inputImages?.length ?? 0) > GOOGLE_MAX_INPUT_IMAGES) {
throw new Error(

View File

@@ -1,7 +1,6 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import path from "node:path";
import { GoogleGenAI } from "@google/genai";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
createProviderOperationDeadline,
@@ -16,15 +15,17 @@ import type {
VideoGenerationRequest,
} from "openclaw/plugin-sdk/video-generation";
import { normalizeGoogleApiBaseUrl } from "./api.js";
import {
createGoogleVideoGenerationProviderMetadata,
DEFAULT_GOOGLE_VIDEO_MODEL,
GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS,
GOOGLE_VIDEO_MAX_DURATION_SECONDS,
GOOGLE_VIDEO_MIN_DURATION_SECONDS,
} from "./generation-provider-metadata.js";
const DEFAULT_GOOGLE_VIDEO_MODEL = "veo-3.1-fast-generate-preview";
const DEFAULT_TIMEOUT_MS = 180_000;
const POLL_INTERVAL_MS = 10_000;
const MAX_POLL_ATTEMPTS = 90;
const GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS = [4, 6, 8] as const;
const GOOGLE_VIDEO_MIN_DURATION_SECONDS = GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS[0];
const GOOGLE_VIDEO_MAX_DURATION_SECONDS =
GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS[GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS.length - 1];
function resolveConfiguredGoogleVideoBaseUrl(req: VideoGenerationRequest): string | undefined {
const configured = normalizeOptionalString(req.cfg?.models?.providers?.google?.baseUrl);
@@ -151,61 +152,7 @@ async function downloadGeneratedVideo(params: {
export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider {
return {
id: "google",
label: "Google",
defaultModel: DEFAULT_GOOGLE_VIDEO_MODEL,
models: [
DEFAULT_GOOGLE_VIDEO_MODEL,
"veo-3.1-generate-preview",
"veo-3.1-lite-generate-preview",
"veo-3.0-fast-generate-001",
"veo-3.0-generate-001",
"veo-2.0-generate-001",
],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "google",
agentDir,
}),
capabilities: {
generate: {
maxVideos: 1,
maxDurationSeconds: GOOGLE_VIDEO_MAX_DURATION_SECONDS,
supportedDurationSeconds: GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS,
aspectRatios: ["16:9", "9:16"],
resolutions: ["720P", "1080P"],
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: true,
supportsAudio: true,
},
imageToVideo: {
enabled: true,
maxVideos: 1,
maxInputImages: 1,
maxDurationSeconds: GOOGLE_VIDEO_MAX_DURATION_SECONDS,
supportedDurationSeconds: GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS,
aspectRatios: ["16:9", "9:16"],
resolutions: ["720P", "1080P"],
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: true,
supportsAudio: true,
},
videoToVideo: {
enabled: true,
maxVideos: 1,
maxInputVideos: 1,
maxDurationSeconds: GOOGLE_VIDEO_MAX_DURATION_SECONDS,
supportedDurationSeconds: GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS,
aspectRatios: ["16:9", "9:16"],
resolutions: ["720P", "1080P"],
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: true,
supportsAudio: true,
},
},
...createGoogleVideoGenerationProviderMetadata(),
async generateVideo(req) {
if ((req.inputImages?.length ?? 0) > 1) {
throw new Error("Google video generation supports at most one input image.");

View File

@@ -8,7 +8,6 @@ import {
} from "openclaw/plugin-sdk/account-resolution";
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
import { isSecretRef } from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { z } from "zod";
import type { GoogleChatAccountConfig } from "./types.config.js";
@@ -28,6 +27,14 @@ const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
const JsonRecordSchema = z.record(z.string(), z.unknown());
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
const {
listAccountIds: listGoogleChatAccountIds,
resolveDefaultAccountId: resolveDefaultGoogleChatAccountId,

View File

@@ -1,3 +1,4 @@
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const listEnabledGoogleChatAccounts = vi.hoisted(() => vi.fn());
@@ -9,7 +10,6 @@ const sendGoogleChatMessage = vi.hoisted(() => vi.fn());
const uploadGoogleChatAttachment = vi.hoisted(() => vi.fn());
const resolveGoogleChatOutboundSpace = vi.hoisted(() => vi.fn());
const getGoogleChatRuntime = vi.hoisted(() => vi.fn());
const loadOutboundMediaFromUrl = vi.hoisted(() => vi.fn());
vi.mock("./accounts.js", () => ({
listEnabledGoogleChatAccounts,
@@ -32,15 +32,6 @@ vi.mock("./targets.js", () => ({
resolveGoogleChatOutboundSpace,
}));
vi.mock("../runtime-api.js", async () => {
const actual = await vi.importActual<typeof import("../runtime-api.js")>("../runtime-api.js");
return {
...actual,
loadOutboundMediaFromUrl: (...args: Parameters<typeof actual.loadOutboundMediaFromUrl>) =>
(loadOutboundMediaFromUrl as unknown as typeof actual.loadOutboundMediaFromUrl)(...args),
};
});
let googlechatMessageActions: typeof import("./actions.js").googlechatMessageActions;
describe("googlechat message actions", () => {
@@ -161,11 +152,9 @@ describe("googlechat message actions", () => {
config: { mediaMaxMb: 5 },
});
resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/BBB");
loadOutboundMediaFromUrl.mockResolvedValue({
buffer: Buffer.from("local-bytes"),
fileName: "local.txt",
contentType: "text/plain",
});
const localRoot = "/tmp/googlechat-action-test";
const localPath = path.join(localRoot, "local.md");
const readFile = vi.fn(async () => Buffer.from("local-bytes"));
getGoogleChatRuntime.mockReturnValue({
channel: {
media: {
@@ -187,23 +176,22 @@ describe("googlechat message actions", () => {
action: "upload-file",
params: {
to: "spaces/BBB",
path: "/tmp/local.txt",
path: localPath,
message: "notes",
filename: "renamed.txt",
},
cfg: {},
accountId: "default",
mediaLocalRoots: ["/tmp"],
mediaLocalRoots: [localRoot],
mediaReadFile: readFile,
} as never);
expect(loadOutboundMediaFromUrl).toHaveBeenCalledWith(
"/tmp/local.txt",
expect.objectContaining({ mediaLocalRoots: ["/tmp"] }),
);
expect(readFile).toHaveBeenCalledWith(localPath);
expect(uploadGoogleChatAttachment).toHaveBeenCalledWith(
expect.objectContaining({
space: "spaces/BBB",
filename: "renamed.txt",
buffer: Buffer.from("local-bytes"),
}),
);
expect(sendGoogleChatMessage).toHaveBeenCalledWith(

View File

@@ -1,17 +1,17 @@
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
OpenClawConfig,
} from "../runtime-api.js";
import {
createActionGate,
extractToolSend,
jsonResult,
loadOutboundMediaFromUrl,
readNumberParam,
readReactionParams,
readStringParam,
} from "../runtime-api.js";
} from "openclaw/plugin-sdk/channel-actions";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import { listEnabledGoogleChatAccounts, resolveGoogleChatAccount } from "./accounts.js";
import {
createGoogleChatReaction,

View File

@@ -1,6 +1,6 @@
import crypto from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { fetchWithSsrFGuard } from "../runtime-api.js";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { getGoogleChatAccessToken } from "./auth.js";
import type { GoogleChatReaction } from "./types.js";

View File

@@ -1,5 +1,4 @@
import { GoogleAuth, OAuth2Client } from "google-auth-library";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
@@ -16,6 +15,10 @@ const verifyClient = new OAuth2Client();
let cachedCerts: { fetchedAt: number; certs: Record<string, string> } | null = null;
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function buildAuthKey(account: ResolvedGoogleChatAccount): string {
if (account.credentialsFile) {
return `file:${account.credentialsFile}`;

View File

@@ -1,11 +1,11 @@
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
import {
createAccountStatusSink,
runPassiveAccountLifecycle,
type OpenClawConfig,
type ResolvedGoogleChatAccount,
} from "./channel.deps.runtime.js";
} from "openclaw/plugin-sdk/channel-lifecycle";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import type { GoogleChatRuntimeEnv } from "./monitor-types.js";
const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(

View File

@@ -18,6 +18,7 @@ import {
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { sendGoogleChatMessage } from "./api.js";
import type { GoogleChatCoreRuntime } from "./monitor-types.js";
import { isSenderAllowed } from "./sender-allow.js";
import type { GoogleChatAnnotation, GoogleChatMessage, GoogleChatSpace } from "./types.js";
function normalizeUserId(raw?: string | null): string {
@@ -28,42 +29,7 @@ function normalizeUserId(raw?: string | null): string {
return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, ""));
}
function isEmailLike(value: string): boolean {
// Keep this intentionally loose; allowlists are user-provided config.
return value.includes("@");
}
export function isSenderAllowed(
senderId: string,
senderEmail: string | undefined,
allowFrom: string[],
allowNameMatching = false,
) {
if (allowFrom.includes("*")) {
return true;
}
const normalizedSenderId = normalizeUserId(senderId);
const normalizedEmail = normalizeLowercaseStringOrEmpty(senderEmail ?? "");
return allowFrom.some((entry) => {
const normalized = normalizeLowercaseStringOrEmpty(entry);
if (!normalized) {
return false;
}
// Accept `googlechat:<id>` but treat `users/...` as an *ID* only (deprecated `users/<email>`).
const withoutPrefix = normalized.replace(/^(googlechat|google-chat|gchat):/i, "");
if (withoutPrefix.startsWith("users/")) {
return normalizeUserId(withoutPrefix) === normalizedSenderId;
}
// Raw email allowlist entries are a break-glass override.
if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) {
return withoutPrefix === normalizedEmail;
}
return withoutPrefix.replace(/^users\//i, "") === normalizedSenderId;
});
}
export { isSenderAllowed } from "./sender-allow.js";
type GoogleChatGroupEntry = {
requireMention?: boolean;

View File

@@ -0,0 +1,55 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { createWebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-request-guards";
import { registerWebhookTargetWithPluginRoute } from "openclaw/plugin-sdk/webhook-targets";
import type { WebhookTarget } from "./monitor-types.js";
import { createGoogleChatWebhookRequestHandler } from "./monitor-webhook.js";
import type { GoogleChatEvent } from "./types.js";
type ProcessGoogleChatEvent = (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
const webhookTargets = new Map<string, WebhookTarget[]>();
const webhookInFlightLimiter = createWebhookInFlightLimiter();
let processGoogleChatEvent: ProcessGoogleChatEvent = async () => {};
export function setGoogleChatWebhookEventProcessor(processEvent: ProcessGoogleChatEvent): void {
processGoogleChatEvent = processEvent;
}
const googleChatWebhookRequestHandler = createGoogleChatWebhookRequestHandler({
webhookTargets,
webhookInFlightLimiter,
processEvent: async (event, target) => {
await processGoogleChatEvent(event, target);
},
});
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
return registerWebhookTargetWithPluginRoute({
targetsByPath: webhookTargets,
target,
route: {
auth: "plugin",
match: "exact",
pluginId: "googlechat",
source: "googlechat-webhook",
accountId: target.account.accountId,
log: target.runtime.log,
handler: async (req, res) => {
const handled = await handleGoogleChatWebhookRequest(req, res);
if (!handled && !res.headersSent) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
}
},
},
}).unregister;
}
export async function handleGoogleChatWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
return await googleChatWebhookRequestHandler(req, res);
}

View File

@@ -1,7 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import type { GoogleChatAudienceType } from "./auth.js";
import { getGoogleChatRuntime } from "./runtime.js";
import type { getGoogleChatRuntime } from "./runtime.js";
export type GoogleChatRuntimeEnv = {
log?: (message: string) => void;

View File

@@ -8,8 +8,11 @@ const resolveWebhookTargetWithAuthOrReject = vi.hoisted(() => vi.fn());
const withResolvedWebhookRequestPipeline = vi.hoisted(() => vi.fn());
const verifyGoogleChatRequest = vi.hoisted(() => vi.fn());
vi.mock("../runtime-api.js", () => ({
vi.mock("openclaw/plugin-sdk/webhook-request-guards", () => ({
readJsonWebhookBodyOrReject,
}));
vi.mock("openclaw/plugin-sdk/webhook-targets", () => ({
resolveWebhookTargetWithAuthOrReject,
withResolvedWebhookRequestPipeline,
}));

View File

@@ -1,11 +1,10 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { WebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-request-guards";
import { readJsonWebhookBodyOrReject } from "openclaw/plugin-sdk/webhook-request-guards";
import {
readJsonWebhookBodyOrReject,
resolveWebhookTargetWithAuthOrReject,
withResolvedWebhookRequestPipeline,
type WebhookInFlightLimiter,
} from "../runtime-api.js";
} from "openclaw/plugin-sdk/webhook-targets";
import { verifyGoogleChatRequest } from "./auth.js";
import type { WebhookTarget } from "./monitor-types.js";
import type {
@@ -15,6 +14,10 @@ import type {
GoogleChatUser,
} from "./types.js";
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function extractBearerToken(header: unknown): string {
const authHeader = Array.isArray(header)
? typeof header[0] === "string"

View File

@@ -1,4 +1,3 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
@@ -7,8 +6,6 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runti
import type { OpenClawConfig } from "../runtime-api.js";
import {
createChannelReplyPipeline,
createWebhookInFlightLimiter,
registerWebhookTargetWithPluginRoute,
resolveInboundRouteEnvelopeBuilderWithRuntime,
resolveWebhookPath,
} from "../runtime-api.js";
@@ -21,27 +18,27 @@ import {
} from "./api.js";
import { type GoogleChatAudienceType } from "./auth.js";
import { applyGoogleChatInboundAccessPolicy, isSenderAllowed } from "./monitor-access.js";
import {
handleGoogleChatWebhookRequest,
registerGoogleChatWebhookTarget,
setGoogleChatWebhookEventProcessor,
} from "./monitor-routing.js";
import type {
GoogleChatCoreRuntime,
GoogleChatMonitorOptions,
GoogleChatRuntimeEnv,
WebhookTarget,
} from "./monitor-types.js";
import { createGoogleChatWebhookRequestHandler } from "./monitor-webhook.js";
import { getGoogleChatRuntime } from "./runtime.js";
import type { GoogleChatAttachment, GoogleChatEvent } from "./types.js";
export type { GoogleChatMonitorOptions, GoogleChatRuntimeEnv } from "./monitor-types.js";
export {
handleGoogleChatWebhookRequest,
registerGoogleChatWebhookTarget,
} from "./monitor-routing.js";
export { isSenderAllowed };
const webhookTargets = new Map<string, WebhookTarget[]>();
const webhookInFlightLimiter = createWebhookInFlightLimiter();
const googleChatWebhookRequestHandler = createGoogleChatWebhookRequestHandler({
webhookTargets,
webhookInFlightLimiter,
processEvent: async (event, target) => {
await processGoogleChatEvent(event, target);
},
});
setGoogleChatWebhookEventProcessor(processGoogleChatEvent);
function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) {
if (core.logging.shouldLogVerbose()) {
@@ -49,29 +46,6 @@ function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv,
}
}
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
return registerWebhookTargetWithPluginRoute({
targetsByPath: webhookTargets,
target,
route: {
auth: "plugin",
match: "exact",
pluginId: "googlechat",
source: "googlechat-webhook",
accountId: target.account.accountId,
log: target.runtime.log,
handler: async (req, res) => {
const handled = await handleGoogleChatWebhookRequest(req, res);
if (!handled && !res.headersSent) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
}
},
},
}).unregister;
}
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
const normalized = normalizeOptionalLowercaseString(value);
if (normalized === "app-url" || normalized === "app_url" || normalized === "app") {
@@ -87,13 +61,6 @@ function normalizeAudienceType(value?: string | null): GoogleChatAudienceType |
return undefined;
}
export async function handleGoogleChatWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
return await googleChatWebhookRequestHandler(req, res);
}
async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) {
const eventType = event.type ?? (event as { eventType?: string }).eventType;
if (eventType !== "MESSAGE") {

View File

@@ -7,7 +7,10 @@ import { createMockServerResponse } from "../../../test/helpers/plugins/mock-htt
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { verifyGoogleChatRequest } from "./auth.js";
import { handleGoogleChatWebhookRequest, registerGoogleChatWebhookTarget } from "./monitor.js";
import {
handleGoogleChatWebhookRequest,
registerGoogleChatWebhookTarget,
} from "./monitor-routing.js";
vi.mock("./auth.js", () => ({
verifyGoogleChatRequest: vi.fn(),

View File

@@ -0,0 +1,48 @@
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function normalizeUserId(raw?: string | null): string {
const trimmed = typeof raw === "string" ? raw.trim() : "";
if (!trimmed) {
return "";
}
return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, ""));
}
function isEmailLike(value: string): boolean {
// Keep this intentionally loose; allowlists are user-provided config.
return value.includes("@");
}
export function isSenderAllowed(
senderId: string,
senderEmail: string | undefined,
allowFrom: string[],
allowNameMatching = false,
) {
if (allowFrom.includes("*")) {
return true;
}
const normalizedSenderId = normalizeUserId(senderId);
const normalizedEmail = normalizeLowercaseStringOrEmpty(senderEmail ?? "");
return allowFrom.some((entry) => {
const normalized = normalizeLowercaseStringOrEmpty(entry);
if (!normalized) {
return false;
}
// Accept `googlechat:<id>` but treat `users/...` as an *ID* only (deprecated `users/<email>`).
const withoutPrefix = normalized.replace(/^(googlechat|google-chat|gchat):/i, "");
if (withoutPrefix.startsWith("users/")) {
return normalizeUserId(withoutPrefix) === normalizedSenderId;
}
// Raw email allowlist entries are a break-glass override.
if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) {
return withoutPrefix === normalizedEmail;
}
return withoutPrefix.replace(/^users\//i, "") === normalizedSenderId;
});
}

View File

@@ -11,10 +11,6 @@ import {
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup";
import {
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import { resolveDefaultGoogleChatAccountId, resolveGoogleChatAccount } from "./accounts.js";
const channel = "googlechat" as const;
@@ -23,6 +19,24 @@ const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
const USE_ENV_FLAG = "__googlechatUseEnv";
const AUTH_METHOD_FLAG = "__googlechatAuthMethod";
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function normalizeStringifiedOptionalString(value: unknown): string | undefined {
if (typeof value === "string") {
return normalizeOptionalString(value);
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return normalizeOptionalString(String(value));
}
return undefined;
}
const promptAllowFrom = createPromptParsedAllowFromForAccount({
defaultAccountId: resolveDefaultGoogleChatAccountId,
message: "Google Chat allowFrom (users/<id> or raw email; avoid users/<email>)",

View File

@@ -11,14 +11,14 @@ import {
expectLifecyclePatch,
expectPendingUntilAbort,
startAccountAndTrackLifecycle,
waitForStartedMocks,
} from "../../../test/helpers/plugins/start-account-lifecycle.js";
import type { OpenClawConfig } from "../runtime-api.js";
import { resolveGoogleChatAccount, type ResolvedGoogleChatAccount } from "./accounts.js";
import {
listGoogleChatAccountIds,
resolveGoogleChatAccount,
resolveDefaultGoogleChatAccountId,
} from "./channel.deps.runtime.js";
type ResolvedGoogleChatAccount,
} from "./accounts.js";
import { startGoogleChatGatewayAccount } from "./gateway.js";
import { googlechatSetupAdapter } from "./setup-core.js";
import { googlechatSetupWizard } from "./setup-surface.js";
@@ -27,13 +27,16 @@ const hoisted = vi.hoisted(() => ({
startGoogleChatMonitor: vi.fn(),
}));
vi.mock("./monitor.js", async () => {
const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
return {
...actual,
vi.mock("./channel.runtime.js", () => ({
googleChatChannelRuntime: {
resolveGoogleChatWebhookPath: ({
account,
}: {
account: { config: { webhookPath?: string } };
}) => account.config.webhookPath ?? "/googlechat",
startGoogleChatMonitor: hoisted.startGoogleChatMonitor,
};
});
},
}));
const googlechatSetupPlugin = {
id: "googlechat",
@@ -65,6 +68,16 @@ function buildAccount(): ResolvedGoogleChatAccount {
};
}
async function waitForGoogleChatMonitorStarted() {
for (let attempt = 0; attempt < 10; attempt += 1) {
if (hoisted.startGoogleChatMonitor.mock.calls.length === 1) {
return;
}
await new Promise<void>((resolve) => setImmediate(resolve));
}
expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce();
}
describe("googlechat setup", () => {
afterEach(() => {
vi.clearAllMocks();
@@ -356,7 +369,7 @@ describe("googlechat setup", () => {
account: buildAccount(),
});
await expectPendingUntilAbort({
waitForStarted: waitForStartedMocks(hoisted.startGoogleChatMonitor),
waitForStarted: waitForGoogleChatMonitorStarted,
isSettled,
abort,
task,

View File

@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js";
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
import { isSenderAllowed } from "./monitor.js";
import { isSenderAllowed } from "./sender-allow.js";
import {
isGoogleChatSpaceTarget,
isGoogleChatUserTarget,
@@ -18,10 +18,8 @@ const mocks = vi.hoisted(() => ({
getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"),
}));
vi.mock("../runtime-api.js", async () => {
const actual = await vi.importActual<typeof import("../runtime-api.js")>("../runtime-api.js");
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => {
return {
...actual,
fetchWithSsrFGuard: mocks.fetchWithSsrFGuard,
};
});

View File

@@ -1,7 +1,10 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { findGoogleChatDirectMessage } from "./api.js";
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
export function normalizeGoogleChatTarget(raw?: string | null): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) {

View File

@@ -7,3 +7,7 @@ export {
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "./src/media-contract.js";
export {
__testing as imessageConversationBindingTesting,
createIMessageConversationBindingManager,
} from "./src/conversation-bindings.js";

View File

@@ -1,10 +1,7 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetLmstudioPreloadCooldownForTest,
wrapLmstudioInferencePreload,
} from "./stream.js";
import { __resetLmstudioPreloadCooldownForTest, wrapLmstudioInferencePreload } from "./stream.js";
const ensureLmstudioModelLoadedMock = vi.hoisted(() => vi.fn());
const resolveLmstudioProviderHeadersMock = vi.hoisted(() =>

View File

@@ -241,10 +241,7 @@ export function wrapLmstudioInferencePreload(ctx: ProviderWrapStreamFnContext):
};
const cause = annotated.cause ?? error;
const failures = annotated.consecutiveFailures ?? 1;
const cooldownSec = Math.max(
0,
Math.round((annotated.cooldownMs ?? 0) / 1000),
);
const cooldownSec = Math.max(0, Math.round((annotated.cooldownMs ?? 0) / 1000));
log.warn(
`LM Studio inference preload failed for "${modelKey}" (${failures} consecutive failure${
failures === 1 ? "" : "s"

View File

@@ -1,18 +1,29 @@
import { describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
import { registerMatrixCliMetadata } from "./cli-metadata.js";
import entry from "./index.js";
import entry, { registerMatrixFullRuntime } from "./index.js";
const cliMocks = vi.hoisted(() => ({
registerMatrixCli: vi.fn(),
}));
const runtimeMocks = vi.hoisted(() => ({
ensureMatrixCryptoRuntime: vi.fn(async () => {}),
handleVerificationBootstrap: vi.fn(async () => {}),
handleVerificationStatus: vi.fn(async () => {}),
handleVerifyRecoveryKey: vi.fn(async () => {}),
setMatrixRuntime: vi.fn(),
}));
vi.mock("./src/cli.js", () => {
return {
registerMatrixCli: cliMocks.registerMatrixCli,
};
});
vi.mock("./plugin-entry.handlers.runtime.js", () => runtimeMocks);
vi.mock("./runtime-api.js", () => ({ setMatrixRuntime: runtimeMocks.setMatrixRuntime }));
describe("matrix plugin", () => {
it("registers matrix CLI through a descriptor-backed lazy registrar", async () => {
const registerCli = vi.fn();
@@ -56,4 +67,30 @@ describe("matrix plugin", () => {
expect(entry.id).toBe("matrix");
expect(entry.name).toBe("Matrix");
});
it("registers subagent lifecycle hooks during full runtime registration", () => {
const on = vi.fn();
const registerGatewayMethod = vi.fn();
const api = createTestPluginApi({
id: "matrix",
name: "Matrix",
source: "test",
config: {},
runtime: {} as never,
registrationMode: "full",
on,
registerGatewayMethod,
});
registerMatrixFullRuntime(api);
expect(on.mock.calls.map(([hookName]) => hookName)).toEqual([
"subagent_spawning",
"subagent_ended",
"subagent_delivery_target",
]);
for (const [, handler] of on.mock.calls) {
expect(handler).toEqual(expect.any(Function));
}
});
});

View File

@@ -1,7 +1,61 @@
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
import {
defineBundledChannelEntry,
type OpenClawPluginApi,
} from "openclaw/plugin-sdk/channel-entry-contract";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { registerMatrixCliMetadata } from "./cli-metadata.js";
type MatrixSubagentHooksModule = typeof import("./src/matrix/subagent-hooks.js");
let matrixSubagentHooksPromise: Promise<MatrixSubagentHooksModule> | null = null;
function loadMatrixSubagentHooksModule() {
matrixSubagentHooksPromise ??= import("./src/matrix/subagent-hooks.js");
return matrixSubagentHooksPromise;
}
export function registerMatrixFullRuntime(api: OpenClawPluginApi): void {
void import("./plugin-entry.handlers.runtime.js")
.then(({ ensureMatrixCryptoRuntime }) =>
ensureMatrixCryptoRuntime({ log: api.logger.info }).catch((err: unknown) => {
const message = formatErrorMessage(err);
api.logger.warn?.(`matrix: crypto runtime bootstrap failed: ${message}`);
}),
)
.catch((err: unknown) => {
const message = formatErrorMessage(err);
api.logger.warn?.(`matrix: failed loading crypto bootstrap runtime: ${message}`);
});
api.registerGatewayMethod("matrix.verify.recoveryKey", async (ctx) => {
const { handleVerifyRecoveryKey } = await import("./plugin-entry.handlers.runtime.js");
await handleVerifyRecoveryKey(ctx);
});
api.registerGatewayMethod("matrix.verify.bootstrap", async (ctx) => {
const { handleVerificationBootstrap } = await import("./plugin-entry.handlers.runtime.js");
await handleVerificationBootstrap(ctx);
});
api.registerGatewayMethod("matrix.verify.status", async (ctx) => {
const { handleVerificationStatus } = await import("./plugin-entry.handlers.runtime.js");
await handleVerificationStatus(ctx);
});
api.on("subagent_spawning", async (event) => {
const { handleMatrixSubagentSpawning } = await loadMatrixSubagentHooksModule();
return await handleMatrixSubagentSpawning(api, event);
});
api.on("subagent_ended", async (event) => {
const { handleMatrixSubagentEnded } = await loadMatrixSubagentHooksModule();
await handleMatrixSubagentEnded(event);
});
api.on("subagent_delivery_target", async (event) => {
const { handleMatrixSubagentDeliveryTarget } = await loadMatrixSubagentHooksModule();
return handleMatrixSubagentDeliveryTarget(event);
});
}
export default defineBundledChannelEntry({
id: "matrix",
name: "Matrix",
@@ -20,32 +74,5 @@ export default defineBundledChannelEntry({
exportName: "setMatrixRuntime",
},
registerCliMetadata: registerMatrixCliMetadata,
registerFull(api) {
void import("./plugin-entry.handlers.runtime.js")
.then(({ ensureMatrixCryptoRuntime }) =>
ensureMatrixCryptoRuntime({ log: api.logger.info }).catch((err: unknown) => {
const message = formatErrorMessage(err);
api.logger.warn?.(`matrix: crypto runtime bootstrap failed: ${message}`);
}),
)
.catch((err: unknown) => {
const message = formatErrorMessage(err);
api.logger.warn?.(`matrix: failed loading crypto bootstrap runtime: ${message}`);
});
api.registerGatewayMethod("matrix.verify.recoveryKey", async (ctx) => {
const { handleVerifyRecoveryKey } = await import("./plugin-entry.handlers.runtime.js");
await handleVerifyRecoveryKey(ctx);
});
api.registerGatewayMethod("matrix.verify.bootstrap", async (ctx) => {
const { handleVerificationBootstrap } = await import("./plugin-entry.handlers.runtime.js");
await handleVerificationBootstrap(ctx);
});
api.registerGatewayMethod("matrix.verify.status", async (ctx) => {
const { handleVerificationStatus } = await import("./plugin-entry.handlers.runtime.js");
await handleVerificationStatus(ctx);
});
},
registerFull: registerMatrixFullRuntime,
});

View File

@@ -10,8 +10,8 @@ import {
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
resolveMatrixAccountStringValues,
type MatrixResolvedStringField,

View File

@@ -1,5 +1,5 @@
import { Type } from "@sinclair/typebox";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
import { resolveDefaultMatrixAccountId, resolveMatrixAccount } from "./matrix/accounts.js";

View File

@@ -14,7 +14,7 @@ import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/text-runtime";
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { getMatrixApprovalAuthApprovers, matrixApprovalAuth } from "./approval-auth.js";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import {

View File

@@ -0,0 +1,45 @@
import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-auth-runtime";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import type { CoreConfig } from "./types.js";
type MatrixApprovalReactionKind = "exec" | "plugin";
function normalizeMatrixExecApproverId(value: string | number): string | undefined {
const normalized = normalizeMatrixApproverId(value);
return normalized === "*" ? undefined : normalized;
}
function getMatrixApprovalReactionApprovers(params: {
cfg: CoreConfig;
accountId?: string | null;
approvalKind: MatrixApprovalReactionKind;
}): string[] {
const account = resolveMatrixAccount(params).config;
if (params.approvalKind === "plugin") {
return resolveApprovalApprovers({
allowFrom: account.dm?.allowFrom,
normalizeApprover: normalizeMatrixApproverId,
});
}
return resolveApprovalApprovers({
explicit: account.execApprovals?.approvers,
allowFrom: account.dm?.allowFrom,
normalizeApprover: normalizeMatrixExecApproverId,
});
}
export function isMatrixApprovalReactionAuthorizedSender(params: {
cfg: CoreConfig;
accountId?: string | null;
senderId?: string | null;
approvalKind: MatrixApprovalReactionKind;
}): boolean {
const normalizedSenderId = params.senderId
? normalizeMatrixApproverId(params.senderId)
: undefined;
if (!normalizedSenderId) {
return false;
}
return getMatrixApprovalReactionApprovers(params).includes(normalizedSenderId);
}

View File

@@ -24,11 +24,11 @@ import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
import { matrixMessageActions } from "./actions.js";
import { matrixApprovalCapability } from "./approval-native.js";
import { createMatrixPairingText, createMatrixProbeAccount } from "./channel-account-paths.js";
@@ -67,6 +67,7 @@ import {
import { matrixSetupAdapter } from "./setup-core.js";
import { matrixSetupWizard } from "./setup-surface.js";
import { runMatrixStartupMaintenance } from "./startup-maintenance.js";
import { resolveMatrixInboundConversation } from "./thread-binding-api.js";
import type { CoreConfig } from "./types.js";
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
let matrixStartupLock: Promise<void> = Promise.resolve();
@@ -267,25 +268,6 @@ function resolveMatrixCommandConversation(params: {
return parentConversationId ? { conversationId: parentConversationId } : null;
}
function resolveMatrixInboundConversation(params: {
to?: string;
conversationId?: string;
threadId?: string | number;
}) {
const rawTarget = params.to?.trim() || params.conversationId?.trim() || "";
const target = rawTarget ? resolveMatrixTargetIdentity(rawTarget) : null;
const parentConversationId = target?.kind === "room" ? target.id : undefined;
const threadId =
params.threadId != null ? normalizeOptionalString(String(params.threadId)) : undefined;
if (threadId) {
return {
conversationId: threadId,
...(parentConversationId ? { parentConversationId } : {}),
};
}
return parentConversationId ? { conversationId: parentConversationId } : null;
}
function resolveMatrixDeliveryTarget(params: {
conversationId: string;
parentConversationId?: string;

View File

@@ -1,6 +1,8 @@
import type { Command } from "commander";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-runtime-shared";
import type { ChannelSetupInput } from "openclaw/plugin-sdk/setup";
import { resolveMatrixAccount, resolveMatrixAccountConfig } from "./matrix/accounts.js";
import { withResolvedActionClient, withStartedActionClient } from "./matrix/actions/client.js";
import { listMatrixOwnDevices, pruneMatrixStaleGatewayDevices } from "./matrix/actions/devices.js";
import { updateMatrixOwnProfile } from "./matrix/actions/profile.js";
import {
@@ -16,14 +18,9 @@ import { resolveMatrixAuthContext } from "./matrix/client.js";
import { setMatrixSdkConsoleLogging, setMatrixSdkLogMode } from "./matrix/client/logging.js";
import { resolveMatrixConfigPath, updateMatrixAccountConfig } from "./matrix/config-update.js";
import { isOpenClawManagedMatrixDevice } from "./matrix/device-health.js";
import {
inspectMatrixDirectRooms,
repairMatrixDirectRooms,
type MatrixDirectRoomCandidate,
} from "./matrix/direct-management.js";
import type { MatrixDirectRoomCandidate } from "./matrix/direct-management.js";
import { formatMatrixErrorMessage } from "./matrix/errors.js";
import { applyMatrixProfileUpdate, type MatrixProfileUpdateResult } from "./profile-update.js";
import { formatZonedTimestamp, normalizeAccountId, type ChannelSetupInput } from "./runtime-api.js";
import { getMatrixRuntime } from "./runtime.js";
import { matrixSetupAdapter } from "./setup-core.js";
import type { CoreConfig } from "./types.js";
@@ -334,6 +331,10 @@ async function inspectMatrixDirectRoom(params: {
accountId: string;
userId: string;
}): Promise<MatrixCliDirectRoomInspection> {
const [{ withResolvedActionClient }, { inspectMatrixDirectRooms }] = await Promise.all([
import("./matrix/actions/client.js"),
import("./matrix/direct-management.js"),
]);
return await withResolvedActionClient(
{ accountId: params.accountId },
async (client) => {
@@ -361,6 +362,10 @@ async function repairMatrixDirectRoom(params: {
}): Promise<MatrixCliDirectRoomRepair> {
const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig;
const account = resolveMatrixAccount({ cfg, accountId: params.accountId });
const [{ withStartedActionClient }, { repairMatrixDirectRooms }] = await Promise.all([
import("./matrix/actions/client.js"),
import("./matrix/direct-management.js"),
]);
return await withStartedActionClient({ accountId: params.accountId }, async (client) => {
const repaired = await repairMatrixDirectRooms({
client,

View File

@@ -1,7 +1,7 @@
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveMatrixAuth } from "./matrix/client.js";
import { MatrixAuthedHttpClient } from "./matrix/sdk/http-client.js";
import { isMatrixQualifiedUserId, normalizeMatrixMessagingTarget } from "./matrix/target-ids.js";

View File

@@ -11,7 +11,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { getMatrixApprovalAuthApprovers } from "./approval-auth.js";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import { listMatrixAccountIds, resolveMatrixAccount } from "./matrix/accounts.js";

View File

@@ -1,6 +1,6 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
resolveConfiguredMatrixAccountIds,
resolveMatrixDefaultOrOnlyAccountId,

View File

@@ -1,4 +1,4 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { fetchMatrixPollMessageSummary, resolveMatrixPollRootEventId } from "../poll-summary.js";
import { isPollEventType } from "../poll-types.js";
import { editMessageMatrix, sendMessageMatrix } from "../send.js";

View File

@@ -1,4 +1,4 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { formatMatrixEncryptionUnavailableError } from "../encryption-guidance.js";

View File

@@ -780,6 +780,7 @@ describe("resolveMatrixAuth", () => {
setMatrixAuthClientDepsForTest({
MatrixClient: MockMatrixClient as unknown as typeof import("./sdk.js").MatrixClient,
ensureMatrixSdkLoggingConfigured: ensureMatrixSdkLoggingConfiguredMock,
retryMinDelayMs: 0,
});
});

View File

@@ -1,7 +1,10 @@
import { formatErrorMessage, type PinnedDispatcherPolicy } from "openclaw/plugin-sdk/infra-runtime";
import { coerceSecretRef } from "openclaw/plugin-sdk/provider-auth";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { retryAsync } from "openclaw/plugin-sdk/retry-runtime";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import {
coerceSecretRef,
normalizeResolvedSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
import {
requiresExplicitMatrixDefaultAccount,
resolveMatrixDefaultOrOnlyAccountId,
@@ -33,6 +36,7 @@ import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
type MatrixAuthClientDeps = {
MatrixClient: typeof import("../sdk.js").MatrixClient;
ensureMatrixSdkLoggingConfigured: typeof import("./logging.js").ensureMatrixSdkLoggingConfigured;
retryMinDelayMs?: number;
};
type MatrixCredentialsReadDeps = {
@@ -55,6 +59,7 @@ const MATRIX_AUTH_REQUEST_RETRY_RE =
export function setMatrixAuthClientDepsForTest(deps?: {
MatrixClient: typeof import("../sdk.js").MatrixClient;
ensureMatrixSdkLoggingConfigured: typeof import("./logging.js").ensureMatrixSdkLoggingConfigured;
retryMinDelayMs?: number;
}): void {
matrixAuthClientDepsForTest = deps;
}
@@ -114,7 +119,7 @@ function credentialsMatchBackfillAuthLineage(params: {
async function retryMatrixAuthRequest<T>(label: string, run: () => Promise<T>): Promise<T> {
return await retryAsync(run, {
attempts: 3,
minDelayMs: 250,
minDelayMs: matrixAuthClientDepsForTest?.retryMinDelayMs ?? 250,
maxDelayMs: 1_500,
jitter: 0.1,
label,

View File

@@ -1,6 +1,6 @@
import fs from "node:fs";
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/infra-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { SsrFPolicy } from "../../runtime-api.js";
import type { MatrixClient } from "../sdk.js";
import { resolveValidatedMatrixHomeserverUrl } from "./config.js";

View File

@@ -1,6 +1,95 @@
export {
hasReadyMatrixEnvAuth,
resolveGlobalMatrixEnvConfig,
resolveMatrixEnvAuthReadiness,
resolveScopedMatrixEnvConfig,
} from "./config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { getMatrixScopedEnvVarNames } from "../../env-vars.js";
type MatrixEnvConfig = {
homeserver: string;
userId: string;
accessToken?: string;
password?: string;
deviceId?: string;
deviceName?: string;
};
function cleanEnv(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
export function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig {
return {
homeserver: cleanEnv(env.MATRIX_HOMESERVER),
userId: cleanEnv(env.MATRIX_USER_ID),
accessToken: cleanEnv(env.MATRIX_ACCESS_TOKEN) || undefined,
password: cleanEnv(env.MATRIX_PASSWORD) || undefined,
deviceId: cleanEnv(env.MATRIX_DEVICE_ID) || undefined,
deviceName: cleanEnv(env.MATRIX_DEVICE_NAME) || undefined,
};
}
export function hasReadyMatrixEnvAuth(config: {
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
}): boolean {
const homeserver = cleanEnv(config.homeserver);
const userId = cleanEnv(config.userId);
const accessToken = cleanEnv(config.accessToken);
const password = cleanEnv(config.password);
return Boolean(homeserver && (accessToken || (userId && password)));
}
export function resolveScopedMatrixEnvConfig(
accountId: string,
env: NodeJS.ProcessEnv = process.env,
): MatrixEnvConfig {
const keys = getMatrixScopedEnvVarNames(accountId);
return {
homeserver: cleanEnv(env[keys.homeserver]),
userId: cleanEnv(env[keys.userId]),
accessToken: cleanEnv(env[keys.accessToken]) || undefined,
password: cleanEnv(env[keys.password]) || undefined,
deviceId: cleanEnv(env[keys.deviceId]) || undefined,
deviceName: cleanEnv(env[keys.deviceName]) || undefined,
};
}
export function resolveMatrixEnvAuthReadiness(
accountId: string,
env: NodeJS.ProcessEnv = process.env,
): {
ready: boolean;
homeserver?: string;
userId?: string;
sourceHint: string;
missingMessage: string;
} {
const normalizedAccountId = normalizeAccountId(accountId);
const scoped = resolveScopedMatrixEnvConfig(normalizedAccountId, env);
const scopedReady = hasReadyMatrixEnvAuth(scoped);
if (normalizedAccountId !== DEFAULT_ACCOUNT_ID) {
const keys = getMatrixScopedEnvVarNames(normalizedAccountId);
return {
ready: scopedReady,
homeserver: scoped.homeserver || undefined,
userId: scoped.userId || undefined,
sourceHint: `${keys.homeserver} (+ auth vars)`,
missingMessage: `Set per-account env vars for "${normalizedAccountId}" (for example ${keys.homeserver} + ${keys.accessToken} or ${keys.userId} + ${keys.password}).`,
};
}
const defaultScoped = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, env);
const global = resolveGlobalMatrixEnvConfig(env);
const defaultScopedReady = hasReadyMatrixEnvAuth(defaultScoped);
const globalReady = hasReadyMatrixEnvAuth(global);
const defaultKeys = getMatrixScopedEnvVarNames(DEFAULT_ACCOUNT_ID);
return {
ready: defaultScopedReady || globalReady,
homeserver: defaultScoped.homeserver || global.homeserver || undefined,
userId: defaultScoped.userId || global.userId || undefined,
sourceHint: "MATRIX_* or MATRIX_DEFAULT_*",
missingMessage:
`Set Matrix env vars for the default account ` +
`(for example MATRIX_HOMESERVER + MATRIX_ACCESS_TOKEN, MATRIX_USER_ID + MATRIX_PASSWORD, ` +
`or ${defaultKeys.homeserver} + ${defaultKeys.accessToken}).`,
};
}

View File

@@ -1,5 +1,5 @@
import net from "node:net";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
function normalizeHost(host: string): string {
const normalized = normalizeLowercaseStringOrEmpty(host).replace(/\.+$/, "");

View File

@@ -1,4 +1,4 @@
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/infra-runtime";
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
import type { SsrFPolicy } from "../../runtime-api.js";
export type MatrixResolvedConfig = {

View File

@@ -0,0 +1,76 @@
import {
assertHttpUrlTargetsPrivateNetwork,
type LookupFn,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { isPrivateOrLoopbackHost } from "./private-network-host.js";
const MATRIX_HTTP_HOMESERVER_ERROR =
"Matrix homeserver must use https:// unless it targets a private or loopback host";
function cleanString(value: unknown, requiredMessage: string): string {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
throw new Error(requiredMessage);
}
return trimmed;
}
export function validateMatrixHomeserverUrl(
homeserver: string,
opts?: { allowPrivateNetwork?: boolean },
): string {
const trimmed = cleanString(homeserver, "Matrix homeserver is required (matrix.homeserver)");
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new Error("Matrix homeserver must be a valid http(s) URL");
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new Error("Matrix homeserver must use http:// or https://");
}
if (!parsed.hostname) {
throw new Error("Matrix homeserver must include a hostname");
}
if (parsed.username || parsed.password) {
throw new Error("Matrix homeserver URL must not include embedded credentials");
}
if (parsed.search || parsed.hash) {
throw new Error("Matrix homeserver URL must not include query strings or fragments");
}
if (
parsed.protocol === "http:" &&
opts?.allowPrivateNetwork !== true &&
!isPrivateOrLoopbackHost(parsed.hostname)
) {
throw new Error(MATRIX_HTTP_HOMESERVER_ERROR);
}
return trimmed;
}
export async function resolveValidatedMatrixHomeserverUrl(
homeserver: string,
opts?: {
dangerouslyAllowPrivateNetwork?: boolean;
allowPrivateNetwork?: boolean;
lookupFn?: LookupFn;
},
): Promise<string> {
const allowPrivateNetwork =
typeof opts?.dangerouslyAllowPrivateNetwork === "boolean"
? opts.dangerouslyAllowPrivateNetwork
: opts?.allowPrivateNetwork;
const normalized = validateMatrixHomeserverUrl(homeserver, {
allowPrivateNetwork,
});
await assertHttpUrlTargetsPrivateNetwork(normalized, {
dangerouslyAllowPrivateNetwork: opts?.dangerouslyAllowPrivateNetwork,
allowPrivateNetwork,
lookupFn: opts?.lookupFn,
errorMessage: MATRIX_HTTP_HOMESERVER_ERROR,
});
return normalized;
}

View File

@@ -1,5 +1,5 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime";
import { coerceSecretRef } from "openclaw/plugin-sdk/secret-ref-runtime";
import { normalizeSecretInputString } from "openclaw/plugin-sdk/setup";
import type { CoreConfig, MatrixConfig } from "../types.js";
import { findMatrixAccountConfig } from "./account-config.js";

View File

@@ -182,13 +182,17 @@ describe("matrix credentials storage", () => {
);
let releaseFirstWrite: (() => void) | undefined;
let firstWriteStarted = false;
let resolveFirstWriteStarted: (() => void) | undefined;
const firstWriteStarted = new Promise<void>((resolve) => {
resolveFirstWriteStarted = resolve;
});
const originalRename = fsPromises.rename.bind(fsPromises);
const renameSpy = vi
.spyOn(fsPromises, "rename")
.mockImplementation(async (...args: Parameters<typeof fsPromises.rename>) => {
if (!firstWriteStarted) {
firstWriteStarted = true;
if (resolveFirstWriteStarted) {
resolveFirstWriteStarted();
resolveFirstWriteStarted = undefined;
await new Promise<void>((resolve) => {
releaseFirstWrite = resolve;
});
@@ -208,9 +212,7 @@ describe("matrix credentials storage", () => {
"default",
);
await vi.waitFor(() => {
expect(firstWriteStarted).toBe(true);
});
await firstWriteStarted;
const newerSavePromise = saveMatrixCredentials(
{

View File

@@ -1,4 +1,4 @@
import { writeJsonFileAtomically } from "../runtime-api.js";
import { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
import { createAsyncLock, type AsyncLock } from "./async-lock.js";
import { loadMatrixCredentials, resolveMatrixCredentialsPath } from "./credentials-read.js";
import type { MatrixStoredCredentials } from "./credentials-read.js";

View File

@@ -4,7 +4,7 @@ import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { RuntimeEnv } from "../runtime-api.js";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
const REQUIRED_MATRIX_PACKAGES = [
"matrix-js-sdk",

View File

@@ -1,5 +1,5 @@
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { inspectMatrixDirectRoomEvidence } from "./direct-room.js";
import type { MatrixClient } from "./sdk.js";
import { EventType, type MatrixDirectAccountData } from "./send/types.js";

View File

@@ -1,15 +1,134 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "../runtime-api.js";
const loadConfigMock = vi.fn(() => ({}));
const resolveTextChunkLimitMock = vi.fn<
(cfg: unknown, channel: unknown, accountId?: unknown) => number
>(() => 4000);
const resolveChunkModeMock = vi.fn<(cfg: unknown, channel: unknown, accountId?: unknown) => string>(
() => "length",
);
const chunkMarkdownTextWithModeMock = vi.fn((text: string) => (text ? [text] : []));
const convertMarkdownTablesMock = vi.fn((text: string) => text);
const sendModuleMocks = vi.hoisted(() => {
const loadConfigMock = vi.fn(() => ({}));
const resolveTextChunkLimitMock = vi.fn<
(cfg: unknown, channel: unknown, accountId?: unknown) => number
>(() => 4000);
const resolveChunkModeMock = vi.fn<
(cfg: unknown, channel: unknown, accountId?: unknown) => string
>(() => "length");
const chunkMarkdownTextWithModeMock = vi.fn((text: string) => (text ? [text] : []));
const convertMarkdownTablesMock = vi.fn((text: string) => text);
const prepareMatrixSingleText = vi.fn(
(text: string, opts: { cfg?: unknown; accountId?: string } = {}) => {
const trimmedText = text.trim();
const convertedText = convertMarkdownTablesMock(trimmedText);
const singleEventLimit = Math.min(
resolveTextChunkLimitMock(opts.cfg ?? {}, "matrix", opts.accountId),
4000,
);
return {
trimmedText,
convertedText,
singleEventLimit,
fitsInSingleEvent: convertedText.length <= singleEventLimit,
};
},
);
const sendSingleTextMessageMatrix = vi.fn(
async (
roomId: string,
text: string,
opts: {
client?: {
sendMessage: (roomId: string, content: Record<string, unknown>) => Promise<string>;
};
cfg?: unknown;
accountId?: string;
msgtype?: string;
includeMentions?: boolean;
live?: boolean;
} = {},
) => {
const prepared = prepareMatrixSingleText(text, {
cfg: opts.cfg,
accountId: opts.accountId,
});
if (!prepared.trimmedText) {
throw new Error("Matrix single-message send requires text");
}
if (!prepared.fitsInSingleEvent) {
throw new Error("Matrix single-message text exceeds limit");
}
const content: Record<string, unknown> = {
msgtype: opts.msgtype ?? "m.text",
body: prepared.convertedText,
};
if (opts.live) {
content["org.matrix.msc4357.live"] = {};
}
const eventId = await opts.client?.sendMessage(roomId, content);
return {
messageId: eventId ?? "unknown",
roomId,
primaryMessageId: eventId ?? "unknown",
messageIds: eventId ? [eventId] : [],
};
},
);
const editMessageMatrix = vi.fn(
async (
roomId: string,
originalEventId: string,
newText: string,
opts: {
client?: {
sendMessage: (roomId: string, content: Record<string, unknown>) => Promise<string>;
};
msgtype?: string;
live?: boolean;
} = {},
) => {
const convertedText = convertMarkdownTablesMock(newText);
const newContent: Record<string, unknown> = {
msgtype: opts.msgtype ?? "m.text",
body: convertedText,
};
if (opts.live) {
newContent["org.matrix.msc4357.live"] = {};
}
const content: Record<string, unknown> = {
...newContent,
body: `* ${convertedText}`,
"m.new_content": newContent,
"m.relates_to": {
rel_type: "m.replace",
event_id: originalEventId,
},
};
if (opts.live) {
content["org.matrix.msc4357.live"] = {};
}
return (await opts.client?.sendMessage(roomId, content)) ?? "";
},
);
return {
chunkMarkdownTextWithModeMock,
convertMarkdownTablesMock,
editMessageMatrix,
loadConfigMock,
prepareMatrixSingleText,
resolveChunkModeMock,
resolveTextChunkLimitMock,
sendSingleTextMessageMatrix,
};
});
const {
chunkMarkdownTextWithModeMock,
convertMarkdownTablesMock,
loadConfigMock,
resolveChunkModeMock,
resolveTextChunkLimitMock,
} = sendModuleMocks;
vi.mock("./send.js", () => ({
editMessageMatrix: sendModuleMocks.editMessageMatrix,
prepareMatrixSingleText: sendModuleMocks.prepareMatrixSingleText,
sendSingleTextMessageMatrix: sendModuleMocks.sendSingleTextMessageMatrix,
}));
const runtimeStub = {
config: { loadConfig: () => loadConfigMock() },
channel: {

View File

@@ -1,5 +1,5 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
export function formatMatrixErrorMessage(err: unknown): string {
return formatErrorMessage(err);

View File

@@ -1,8 +1,6 @@
import MarkdownIt from "markdown-it";
import {
isAutoLinkedFileRef,
normalizeLowercaseStringOrEmpty,
} from "openclaw/plugin-sdk/text-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { isAutoLinkedFileRef } from "openclaw/plugin-sdk/text-autolink-runtime";
import type { MatrixClient } from "./sdk.js";
import { isMatrixQualifiedUserId } from "./target-ids.js";

View File

@@ -1,6 +1,7 @@
import { resolveAckReaction } from "openclaw/plugin-sdk/channel-feedback";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { CoreConfig } from "../../types.js";
import { resolveMatrixAccountConfig } from "../account-config.js";
import { resolveAckReaction, type OpenClawConfig } from "./runtime-api.js";
type MatrixAckReactionScope = "group-mentions" | "group-all" | "direct" | "all" | "none" | "off";

View File

@@ -2,8 +2,8 @@ import {
resolveAllowlistMatchByCandidates,
type AllowlistMatch,
} from "openclaw/plugin-sdk/allow-from";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-normalization-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
function normalizeAllowList(list?: Array<string | number>) {
return normalizeStringEntries(list);

View File

@@ -1,4 +1,4 @@
import { normalizeStringifiedOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeStringifiedOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { getMatrixRuntime } from "../../runtime.js";
import type { MatrixConfig } from "../../types.js";
import type { MatrixClient } from "../sdk.js";

View File

@@ -73,8 +73,27 @@ function createHarness(params?: {
emoji?: Array<[string, string]>;
};
} | null>;
sasNoticeRetryDelayMs?: number;
}) {
const listeners = new Map<string, (...args: unknown[]) => void>();
const pendingTasks = new Set<Promise<void>>();
const runDetachedTask = vi.fn((_label: string, task: () => Promise<void>) => {
const promise = Promise.resolve()
.then(task)
.catch((error) => {
throw error;
})
.finally(() => {
pendingTasks.delete(promise);
});
pendingTasks.add(promise);
return promise;
});
const flushTasks = async () => {
while (pendingTasks.size > 0) {
await Promise.all(Array.from(pendingTasks));
}
};
const onRoomMessage = vi.fn(async () => {});
const listVerifications = vi.fn(async () => params?.verifications ?? []);
const ensureVerificationDmTracked = vi.fn(
@@ -154,6 +173,8 @@ function createHarness(params?: {
(typeof params?.startupMs === "number" ? () => params.startupMs : undefined),
formatNativeDependencyHint,
onRoomMessage,
runDetachedTask,
sasNoticeRetryDelayMs: params?.sasNoticeRetryDelayMs ?? 0,
});
const roomEventListener = listeners.get("room.event") as RoomEventListener | undefined;
@@ -172,6 +193,8 @@ function createHarness(params?: {
logger,
formatNativeDependencyHint,
logVerboseMessage,
flushTasks,
runDetachedTask,
roomMessageListener: listeners.get("room.message") as RoomEventListener | undefined,
failedDecryptListener: listeners.get("room.failed_decryption") as
| FailedDecryptListener
@@ -209,7 +232,7 @@ describe("registerMatrixMonitorEvents verification routing", () => {
});
it("still posts fresh verification completions", async () => {
const { sendMessage, roomEventListener } = createHarness();
const { sendMessage, roomEventListener, flushTasks } = createHarness();
roomEventListener("!room:example.org", {
event_id: "$done-fresh",
@@ -221,17 +244,15 @@ describe("registerMatrixMonitorEvents verification routing", () => {
},
});
await vi.dynamicImportSettled();
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
await flushTasks();
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(getSentNoticeBody(sendMessage)).toContain(
"Matrix verification completed with @alice:example.org.",
);
});
it("forwards reaction room events into the shared room handler", async () => {
const { onRoomMessage, sendMessage, roomEventListener } = createHarness();
const { onRoomMessage, sendMessage, roomEventListener, flushTasks } = createHarness();
roomEventListener("!room:example.org", {
event_id: "$reaction1",
@@ -247,12 +268,11 @@ describe("registerMatrixMonitorEvents verification routing", () => {
},
});
await vi.waitFor(() => {
expect(onRoomMessage).toHaveBeenCalledWith(
"!room:example.org",
expect.objectContaining({ event_id: "$reaction1", type: EventType.Reaction }),
);
});
await flushTasks();
expect(onRoomMessage).toHaveBeenCalledWith(
"!room:example.org",
expect.objectContaining({ event_id: "$reaction1", type: EventType.Reaction }),
);
expect(sendMessage).not.toHaveBeenCalled();
});
@@ -359,7 +379,7 @@ describe("registerMatrixMonitorEvents verification routing", () => {
});
it("posts verification request notices directly into the room", async () => {
const { onRoomMessage, sendMessage, roomMessageListener } = createHarness();
const { onRoomMessage, sendMessage, roomMessageListener, flushTasks } = createHarness();
if (!roomMessageListener) {
throw new Error("room.message listener was not registered");
}
@@ -374,9 +394,8 @@ describe("registerMatrixMonitorEvents verification routing", () => {
},
});
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
await flushTasks();
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(onRoomMessage).not.toHaveBeenCalled();
const body = getSentNoticeBody(sendMessage, 0);
expect(body).toContain("Matrix verification request received from @alice:example.org.");
@@ -384,9 +403,10 @@ describe("registerMatrixMonitorEvents verification routing", () => {
});
it("blocks verification request notices when dmPolicy pairing would block the sender", async () => {
const { onRoomMessage, sendMessage, roomMessageListener, logVerboseMessage } = createHarness({
dmPolicy: "pairing",
});
const { onRoomMessage, sendMessage, roomMessageListener, logVerboseMessage, flushTasks } =
createHarness({
dmPolicy: "pairing",
});
if (!roomMessageListener) {
throw new Error("room.message listener was not registered");
}
@@ -402,17 +422,16 @@ describe("registerMatrixMonitorEvents verification routing", () => {
},
});
await vi.waitFor(() => {
expect(logVerboseMessage).toHaveBeenCalledWith(
expect.stringContaining("blocked verification sender @alice:example.org"),
);
});
await flushTasks();
expect(logVerboseMessage).toHaveBeenCalledWith(
expect.stringContaining("blocked verification sender @alice:example.org"),
);
expect(sendMessage).not.toHaveBeenCalled();
expect(onRoomMessage).not.toHaveBeenCalled();
});
it("allows verification notices for pairing-authorized DM senders from the allow store", async () => {
const { sendMessage, roomMessageListener, readStoreAllowFrom } = createHarness({
const { sendMessage, roomMessageListener, readStoreAllowFrom, flushTasks } = createHarness({
dmPolicy: "pairing",
storeAllowFrom: ["@alice:example.org"],
});
@@ -431,14 +450,13 @@ describe("registerMatrixMonitorEvents verification routing", () => {
},
});
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
await flushTasks();
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(readStoreAllowFrom).toHaveBeenCalled();
});
it("does not consult the allow store when dmPolicy is open", async () => {
const { sendMessage, roomMessageListener, readStoreAllowFrom } = createHarness({
const { sendMessage, roomMessageListener, readStoreAllowFrom, flushTasks } = createHarness({
dmPolicy: "open",
});
if (!roomMessageListener) {
@@ -456,14 +474,13 @@ describe("registerMatrixMonitorEvents verification routing", () => {
},
});
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
await flushTasks();
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(readStoreAllowFrom).not.toHaveBeenCalled();
});
it("blocks verification notices when Matrix DMs are disabled", async () => {
const { sendMessage, roomMessageListener, logVerboseMessage } = createHarness({
const { sendMessage, roomMessageListener, logVerboseMessage, flushTasks } = createHarness({
dmEnabled: false,
});
if (!roomMessageListener) {
@@ -481,16 +498,15 @@ describe("registerMatrixMonitorEvents verification routing", () => {
},
});
await vi.waitFor(() => {
expect(logVerboseMessage).toHaveBeenCalledWith(
expect.stringContaining("blocked verification sender @alice:example.org"),
);
});
await flushTasks();
expect(logVerboseMessage).toHaveBeenCalledWith(
expect.stringContaining("blocked verification sender @alice:example.org"),
);
expect(sendMessage).not.toHaveBeenCalled();
});
it("posts ready-stage guidance for emoji verification", async () => {
const { sendMessage, roomEventListener } = createHarness();
const { sendMessage, roomEventListener, flushTasks } = createHarness();
roomEventListener("!room:example.org", {
event_id: "$ready-1",
sender: "@alice:example.org",
@@ -501,9 +517,8 @@ describe("registerMatrixMonitorEvents verification routing", () => {
},
});
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
await flushTasks();
expect(sendMessage).toHaveBeenCalledTimes(1);
const body = getSentNoticeBody(sendMessage, 0);
expect(body).toContain("Matrix verification is ready with @alice:example.org.");
expect(body).toContain('Choose "Verify by emoji"');
@@ -514,6 +529,7 @@ describe("registerMatrixMonitorEvents verification routing", () => {
sendMessage,
roomEventListener,
listVerifications: _listVerifications,
flushTasks,
} = createHarness({
joinedMembersByRoom: {
"!dm:example.org": ["@alice:example.org", "@bot:example.org"],
@@ -546,11 +562,10 @@ describe("registerMatrixMonitorEvents verification routing", () => {
},
});
await vi.waitFor(() => {
const bodies = getSentNoticeBodies(sendMessage);
expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true);
expect(bodies.some((body) => body.includes("SAS decimal: 6158 1986 3513"))).toBe(true);
});
await flushTasks();
const bodies = getSentNoticeBodies(sendMessage);
expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true);
expect(bodies.some((body) => body.includes("SAS decimal: 6158 1986 3513"))).toBe(true);
});
it("rehydrates an in-progress DM verification before resolving SAS notices", async () => {
@@ -569,7 +584,7 @@ describe("registerMatrixMonitorEvents verification routing", () => {
emoji?: Array<[string, string]>;
};
}> = [];
const { sendMessage, roomEventListener } = createHarness({
const { sendMessage, roomEventListener, flushTasks } = createHarness({
joinedMembersByRoom: {
"!dm:example.org": ["@alice:example.org", "@bot:example.org"],
},
@@ -607,14 +622,14 @@ describe("registerMatrixMonitorEvents verification routing", () => {
},
});
await vi.waitFor(() => {
const bodies = getSentNoticeBodies(sendMessage);
expect(bodies.some((body) => body.includes("SAS decimal: 2468 1357 9753"))).toBe(true);
});
await flushTasks();
expect(
getSentNoticeBodies(sendMessage).some((body) => body.includes("SAS decimal: 2468 1357 9753")),
).toBe(true);
});
it("posts SAS notices directly from verification summary updates", async () => {
const { sendMessage, verificationSummaryListener } = createHarness({
const { sendMessage, verificationSummaryListener, flushTasks } = createHarness({
joinedMembersByRoom: {
"!dm:example.org": ["@alice:example.org", "@bot:example.org"],
},
@@ -649,21 +664,21 @@ describe("registerMatrixMonitorEvents verification routing", () => {
updatedAt: new Date("2026-02-25T21:42:55.000Z").toISOString(),
});
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
await flushTasks();
expect(sendMessage).toHaveBeenCalledTimes(1);
const body = getSentNoticeBody(sendMessage, 0);
expect(body).toContain("Matrix verification SAS with @alice:example.org:");
expect(body).toContain("SAS decimal: 6158 1986 3513");
});
it("blocks summary SAS notices when dmPolicy allowlist would block the sender", async () => {
const { sendMessage, verificationSummaryListener, logVerboseMessage } = createHarness({
dmPolicy: "allowlist",
joinedMembersByRoom: {
"!dm:example.org": ["@alice:example.org", "@bot:example.org"],
},
});
const { sendMessage, verificationSummaryListener, logVerboseMessage, flushTasks } =
createHarness({
dmPolicy: "allowlist",
joinedMembersByRoom: {
"!dm:example.org": ["@alice:example.org", "@bot:example.org"],
},
});
if (!verificationSummaryListener) {
throw new Error("verification.summary listener was not registered");
}
@@ -694,20 +709,20 @@ describe("registerMatrixMonitorEvents verification routing", () => {
updatedAt: new Date("2026-02-25T21:42:55.000Z").toISOString(),
});
await vi.waitFor(() => {
expect(logVerboseMessage).toHaveBeenCalledWith(
expect.stringContaining("blocked verification sender @alice:example.org"),
);
});
await flushTasks();
expect(logVerboseMessage).toHaveBeenCalledWith(
expect.stringContaining("blocked verification sender @alice:example.org"),
);
expect(sendMessage).not.toHaveBeenCalled();
});
it("posts SAS notices from summary updates using the room mapped by earlier flow events", async () => {
const { sendMessage, roomEventListener, verificationSummaryListener } = createHarness({
joinedMembersByRoom: {
"!dm:example.org": ["@alice:example.org", "@bot:example.org"],
},
});
const { sendMessage, roomEventListener, verificationSummaryListener, flushTasks } =
createHarness({
joinedMembersByRoom: {
"!dm:example.org": ["@alice:example.org", "@bot:example.org"],
},
});
if (!verificationSummaryListener) {
throw new Error("verification.summary listener was not registered");
}
@@ -749,14 +764,14 @@ describe("registerMatrixMonitorEvents verification routing", () => {
updatedAt: new Date("2026-02-25T21:42:55.000Z").toISOString(),
});
await vi.waitFor(() => {
const bodies = getSentNoticeBodies(sendMessage);
expect(bodies.some((body) => body.includes("SAS decimal: 1111 2222 3333"))).toBe(true);
});
await flushTasks();
expect(
getSentNoticeBodies(sendMessage).some((body) => body.includes("SAS decimal: 1111 2222 3333")),
).toBe(true);
});
it("posts SAS notices from summary updates using the active strict DM when room mapping is missing", async () => {
const { sendMessage, verificationSummaryListener } = createHarness({
const { sendMessage, verificationSummaryListener, flushTasks } = createHarness({
joinedMembersByRoom: {
"!dm-active:example.org": ["@alice:example.org", "@bot:example.org"],
},
@@ -790,9 +805,8 @@ describe("registerMatrixMonitorEvents verification routing", () => {
updatedAt: new Date("2026-02-25T21:42:55.000Z").toISOString(),
});
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
await flushTasks();
expect(sendMessage).toHaveBeenCalledTimes(1);
const roomId = ((sendMessage.mock.calls as unknown[][])[0]?.[0] ?? "") as string;
const body = getSentNoticeBody(sendMessage, 0);
expect(roomId).toBe("!dm-active:example.org");
@@ -800,12 +814,13 @@ describe("registerMatrixMonitorEvents verification routing", () => {
});
it("prefers the canonical active DM over the most recent verification room for unmapped SAS summaries", async () => {
const { sendMessage, roomEventListener, verificationSummaryListener } = createHarness({
joinedMembersByRoom: {
"!dm-active:example.org": ["@alice:example.org", "@bot:example.org"],
"!dm-current:example.org": ["@alice:example.org", "@bot:example.org"],
},
});
const { sendMessage, roomEventListener, verificationSummaryListener, flushTasks } =
createHarness({
joinedMembersByRoom: {
"!dm-active:example.org": ["@alice:example.org", "@bot:example.org"],
"!dm-current:example.org": ["@alice:example.org", "@bot:example.org"],
},
});
if (!verificationSummaryListener) {
throw new Error("verification.summary listener was not registered");
}
@@ -820,10 +835,12 @@ describe("registerMatrixMonitorEvents verification routing", () => {
},
});
await vi.waitFor(() => {
const bodies = getSentNoticeBodies(sendMessage);
expect(bodies.some((body) => body.includes("Matrix verification started with"))).toBe(true);
});
await flushTasks();
expect(
getSentNoticeBodies(sendMessage).some((body) =>
body.includes("Matrix verification started with"),
),
).toBe(true);
verificationSummaryListener({
id: "verification-current-room",
@@ -850,10 +867,10 @@ describe("registerMatrixMonitorEvents verification routing", () => {
updatedAt: new Date("2026-02-25T21:42:55.000Z").toISOString(),
});
await vi.waitFor(() => {
const bodies = getSentNoticeBodies(sendMessage);
expect(bodies.some((body) => body.includes("SAS decimal: 2468 1357 9753"))).toBe(true);
});
await flushTasks();
expect(
getSentNoticeBodies(sendMessage).some((body) => body.includes("SAS decimal: 2468 1357 9753")),
).toBe(true);
const calls = sendMessage.mock.calls as unknown[][];
const sasCall = calls.find((call) =>
getSentNoticeBodyFromCall(call).includes("SAS decimal: 2468 1357 9753"),
@@ -885,6 +902,7 @@ describe("registerMatrixMonitorEvents verification routing", () => {
"!dm:example.org": ["@alice:example.org", "@bot:example.org"],
},
verifications,
sasNoticeRetryDelayMs: 750,
});
try {

View File

@@ -1,4 +1,4 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { PluginRuntime, RuntimeLogger } from "../../runtime-api.js";
import type { CoreConfig } from "../../types.js";
import type { MatrixAuth } from "../client.js";
@@ -186,6 +186,7 @@ export function registerMatrixMonitorEvents(params: {
formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"];
onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise<void>;
runDetachedTask?: (label: string, task: () => Promise<void>) => Promise<void>;
sasNoticeRetryDelayMs?: number;
}): void {
const {
cfg,
@@ -205,6 +206,7 @@ export function registerMatrixMonitorEvents(params: {
formatNativeDependencyHint,
onRoomMessage,
runDetachedTask,
sasNoticeRetryDelayMs,
} = params;
const postHealthySyncDecryptFailureTracker = createMatrixPostHealthySyncDecryptFailureTracker({
getHealthySyncSinceMs,
@@ -217,6 +219,8 @@ export function registerMatrixMonitorEvents(params: {
dmPolicy,
readStoreAllowFrom,
logVerboseMessage,
runDetachedTask,
sasNoticeRetryDelayMs,
});
const runMonitorTask = (label: string, task: () => Promise<void>) => {

View File

@@ -25,6 +25,35 @@ import {
} from "./handler.test-helpers.js";
import { type MatrixRawEvent } from "./types.js";
const deliverMatrixRepliesMock = vi.hoisted(() => vi.fn(async () => true));
vi.mock("./replies.js", () => ({
deliverMatrixReplies: deliverMatrixRepliesMock,
}));
vi.mock("./route.js", () => ({
resolveMatrixInboundRoute: (params: {
resolveAgentRoute: (input: unknown) => unknown;
cfg: unknown;
accountId: string;
roomId: string;
senderId: string;
isDirectMessage: boolean;
}) => ({
route: params.resolveAgentRoute({
cfg: params.cfg,
channel: "matrix",
accountId: params.accountId,
peer: {
kind: params.isDirectMessage ? "direct" : "channel",
id: params.isDirectMessage ? params.senderId : params.roomId,
},
}),
configuredBinding: null,
runtimeBindingId: null,
}),
}));
const DEFAULT_ROOM = "!room:example.org";
function makeRoomTriggerEvent(params: { eventId: string; body: string; ts?: number }) {

View File

@@ -1,11 +1,10 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { recordSessionMetaFromInbound } from "openclaw/plugin-sdk/config-runtime";
import {
__testing as sessionBindingTesting,
registerSessionBindingAdapter,
} from "openclaw/plugin-sdk/conversation-runtime";
} from "openclaw/plugin-sdk/session-binding-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { installMatrixMonitorTestRuntime } from "../../test-runtime.js";
import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js";
@@ -54,6 +53,42 @@ vi.mock("./replies.js", () => ({
deliverMatrixReplies: deliverMatrixRepliesMock,
}));
function writeMatrixSessionMeta(
storePath: string,
sessionKey: string,
origin: {
chatType: "direct" | "group";
from: string;
to: string;
nativeChannelId?: string;
nativeDirectUserId?: string;
},
): void {
const store = fs.existsSync(storePath)
? (JSON.parse(fs.readFileSync(storePath, "utf8")) as Record<string, Record<string, unknown>>)
: {};
const existing = store[sessionKey] ?? {
sessionId: `sess-${Object.keys(store).length + 1}`,
updatedAt: Date.now(),
};
const existingOrigin =
typeof existing.origin === "object" && existing.origin !== null
? (existing.origin as Record<string, unknown>)
: {};
store[sessionKey] = {
...existing,
origin: {
...existingOrigin,
provider: "matrix",
surface: "matrix",
accountId: "ops",
...origin,
},
};
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf8");
}
beforeEach(() => {
sessionBindingTesting.resetSessionBindingAdaptersForTests();
installMatrixMonitorTestRuntime();
@@ -776,21 +811,11 @@ describe("matrix monitor handler pairing account scope", () => {
const sendNotice = vi.fn(async () => "$notice");
try {
await recordSessionMetaFromInbound({
storePath,
sessionKey: "agent:ops:main",
ctx: {
SessionKey: "agent:ops:main",
AccountId: "ops",
ChatType: "direct",
Provider: "matrix",
Surface: "matrix",
From: "matrix:@user:example.org",
To: "room:!other:example.org",
NativeChannelId: "!other:example.org",
OriginatingChannel: "matrix",
OriginatingTo: "room:!other:example.org",
},
writeMatrixSessionMeta(storePath, "agent:ops:main", {
chatType: "direct",
from: "matrix:@user:example.org",
to: "room:!other:example.org",
nativeChannelId: "!other:example.org",
});
const { handler } = createMatrixHandlerTestHarness({
@@ -837,21 +862,11 @@ describe("matrix monitor handler pairing account scope", () => {
const sendNotice = vi.fn(async () => "$notice");
try {
await recordSessionMetaFromInbound({
storePath,
sessionKey: "agent:ops:matrix:direct:@user:example.org",
ctx: {
SessionKey: "agent:ops:matrix:direct:@user:example.org",
AccountId: "ops",
ChatType: "direct",
Provider: "matrix",
Surface: "matrix",
From: "matrix:@user:example.org",
To: "room:!other:example.org",
NativeChannelId: "!other:example.org",
OriginatingChannel: "matrix",
OriginatingTo: "room:!other:example.org",
},
writeMatrixSessionMeta(storePath, "agent:ops:matrix:direct:@user:example.org", {
chatType: "direct",
from: "matrix:@user:example.org",
to: "room:!other:example.org",
nativeChannelId: "!other:example.org",
});
const { handler } = createMatrixHandlerTestHarness({
@@ -896,21 +911,11 @@ describe("matrix monitor handler pairing account scope", () => {
const sendNotice = vi.fn(async () => "$notice");
try {
await recordSessionMetaFromInbound({
storePath,
sessionKey: "agent:ops:main",
ctx: {
SessionKey: "agent:ops:main",
AccountId: "ops",
ChatType: "direct",
Provider: "matrix",
Surface: "matrix",
From: "matrix:@user:example.org",
To: "room:!other:example.org",
NativeChannelId: "!other:example.org",
OriginatingChannel: "matrix",
OriginatingTo: "room:!other:example.org",
},
writeMatrixSessionMeta(storePath, "agent:ops:main", {
chatType: "direct",
from: "matrix:@user:example.org",
to: "room:!other:example.org",
nativeChannelId: "!other:example.org",
});
const { handler } = createMatrixHandlerTestHarness({
@@ -963,37 +968,17 @@ describe("matrix monitor handler pairing account scope", () => {
const sendNotice = vi.fn(async () => "$notice");
try {
await recordSessionMetaFromInbound({
storePath,
sessionKey: "agent:ops:main",
ctx: {
SessionKey: "agent:ops:main",
AccountId: "ops",
ChatType: "direct",
Provider: "matrix",
Surface: "matrix",
From: "matrix:@user:example.org",
To: "room:!other:example.org",
NativeChannelId: "!other:example.org",
OriginatingChannel: "matrix",
OriginatingTo: "room:!other:example.org",
},
writeMatrixSessionMeta(storePath, "agent:ops:main", {
chatType: "direct",
from: "matrix:@user:example.org",
to: "room:!other:example.org",
nativeChannelId: "!other:example.org",
});
await recordSessionMetaFromInbound({
storePath,
sessionKey: "agent:ops:main",
ctx: {
SessionKey: "agent:ops:main",
AccountId: "ops",
ChatType: "direct",
Provider: "matrix",
Surface: "matrix",
From: "matrix:@other:example.org",
To: "room:@other:example.org",
NativeDirectUserId: "@user:example.org",
OriginatingChannel: "matrix",
OriginatingTo: "room:@other:example.org",
},
writeMatrixSessionMeta(storePath, "agent:ops:main", {
chatType: "direct",
from: "matrix:@other:example.org",
to: "room:@other:example.org",
nativeDirectUserId: "@user:example.org",
});
const { handler } = createMatrixHandlerTestHarness({
@@ -1030,21 +1015,11 @@ describe("matrix monitor handler pairing account scope", () => {
const sendNotice = vi.fn(async () => "$notice");
try {
await recordSessionMetaFromInbound({
storePath,
sessionKey: "agent:ops:main",
ctx: {
SessionKey: "agent:ops:main",
AccountId: "ops",
ChatType: "group",
Provider: "matrix",
Surface: "matrix",
From: "matrix:channel:!group:example.org",
To: "room:!group:example.org",
NativeChannelId: "!group:example.org",
OriginatingChannel: "matrix",
OriginatingTo: "room:!group:example.org",
},
writeMatrixSessionMeta(storePath, "agent:ops:main", {
chatType: "group",
from: "matrix:channel:!group:example.org",
to: "room:!group:example.org",
nativeChannelId: "!group:example.org",
});
const { handler } = createMatrixHandlerTestHarness({
@@ -2167,6 +2142,15 @@ describe("matrix monitor handler draft streaming", () => {
}) {
let capturedDeliver: DeliverFn | undefined;
let capturedReplyOpts: ReplyOpts | undefined;
let resolveCaptured: (() => void) | undefined;
const captured = new Promise<void>((resolve) => {
resolveCaptured = resolve;
});
const notifyCaptured = () => {
if (capturedDeliver && capturedReplyOpts) {
resolveCaptured?.();
}
};
// Gate that keeps the handler's model run alive until the test releases it.
let resolveRunGate: (() => void) | undefined;
const runGate = new Promise<void>((resolve) => {
@@ -2189,6 +2173,7 @@ describe("matrix monitor handler draft streaming", () => {
client: { redactEvent: redactEventMock },
createReplyDispatcherWithTyping: (params: Record<string, unknown> | undefined) => {
capturedDeliver = params?.deliver as DeliverFn | undefined;
notifyCaptured();
return {
dispatcher: {
markComplete: () => {},
@@ -2201,6 +2186,7 @@ describe("matrix monitor handler draft streaming", () => {
},
dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
capturedReplyOpts = args?.replyOptions;
notifyCaptured();
// Block until the test is done exercising callbacks.
await runGate;
return { queuedFinal: true, counts: { final: 1, block: 0, tool: 0 } };
@@ -2222,12 +2208,7 @@ describe("matrix monitor handler draft streaming", () => {
"!room:example.org",
createMatrixTextMessageEvent({ eventId: "$msg1", body: "hello" }),
);
// Wait for callbacks to be captured.
await vi.waitFor(() => {
if (!capturedDeliver || !capturedReplyOpts) {
throw new Error("Streaming callbacks not captured yet");
}
});
await captured;
return {
deliver: capturedDeliver!,
opts: capturedReplyOpts!,

View File

@@ -1,19 +1,19 @@
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-gating";
import {
evaluateSupplementalContextVisibility,
resolveChannelContextVisibilityMode,
} from "openclaw/plugin-sdk/context-visibility-runtime";
import {
loadSessionStore,
resolveChannelContextVisibilityMode,
resolveSessionStoreEntry,
} from "openclaw/plugin-sdk/config-runtime";
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
} from "openclaw/plugin-sdk/session-store-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type {
CoreConfig,
MatrixRoomConfig,
MatrixStreamingMode,
ReplyToMode,
} from "../../types.js";
import { createMatrixDraftStream } from "../draft-stream.js";
import { formatMatrixErrorMessage } from "../errors.js";
import { isMatrixMediaSizeLimitError } from "../media-errors.js";
import {
@@ -31,13 +31,6 @@ import {
parsePollStartContent,
} from "../poll-types.js";
import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
import {
editMessageMatrix,
reactMatrixMessage,
sendMessageMatrix,
sendReadReceiptMatrix,
sendTypingMatrix,
} from "../send.js";
import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js";
import { resolveMatrixStoredSessionMeta } from "../session-store-metadata.js";
import { resolveMatrixMonitorAccessState } from "./access-state.js";
@@ -47,7 +40,6 @@ import type { MatrixInboundEventDeduper } from "./inbound-dedupe.js";
import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
import { downloadMatrixMedia } from "./media.js";
import { resolveMentions } from "./mentions.js";
import { handleInboundMatrixReaction } from "./reaction-events.js";
import { deliverMatrixReplies } from "./replies.js";
import { createMatrixReplyContextResolver } from "./reply-context.js";
import { createRoomHistoryTracker } from "./room-history.js";
@@ -57,7 +49,6 @@ import { resolveMatrixInboundRoute } from "./route.js";
import {
createReplyPrefixOptions,
createTypingCallbacks,
ensureConfiguredAcpBindingReady,
formatAllowlistMatchMeta,
getAgentScopedMediaLocalRoots,
logInboundDrop,
@@ -80,9 +71,44 @@ import { isMatrixVerificationRoomMessage } from "./verification-utils.js";
const ALLOW_FROM_STORE_CACHE_TTL_MS = 30_000;
const PAIRING_REPLY_COOLDOWN_MS = 5 * 60_000;
let matrixSendModulePromise: Promise<typeof import("../send.js")> | undefined;
let acpBindingRuntimePromise:
| Promise<typeof import("openclaw/plugin-sdk/acp-binding-runtime")>
| undefined;
let sessionBindingRuntimePromise:
| Promise<typeof import("openclaw/plugin-sdk/session-binding-runtime")>
| undefined;
function loadMatrixSendModule(): Promise<typeof import("../send.js")> {
matrixSendModulePromise ??= import("../send.js");
return matrixSendModulePromise;
}
function loadAcpBindingRuntime(): Promise<
typeof import("openclaw/plugin-sdk/acp-binding-runtime")
> {
acpBindingRuntimePromise ??= import("openclaw/plugin-sdk/acp-binding-runtime");
return acpBindingRuntimePromise;
}
function loadSessionBindingRuntime(): Promise<
typeof import("openclaw/plugin-sdk/session-binding-runtime")
> {
sessionBindingRuntimePromise ??= import("openclaw/plugin-sdk/session-binding-runtime");
return sessionBindingRuntimePromise;
}
const MAX_TRACKED_PAIRING_REPLY_SENDERS = 512;
const MAX_TRACKED_SHARED_DM_CONTEXT_NOTICES = 512;
type MatrixAllowBotsMode = "off" | "mentions" | "all";
type MatrixDraftStreamHandle = {
update: (text: string) => void;
stop: () => Promise<string | undefined>;
eventId: () => string | undefined;
mustDeliverFinalNormally: () => boolean;
matchesPreparedText: (text: string) => boolean;
finalizeLive: () => Promise<boolean>;
reset: () => void;
};
export class MatrixRetryableInboundError extends Error {
constructor(message: string, options?: ErrorOptions) {
@@ -426,7 +452,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
return async (roomId: string, event: MatrixRawEvent) => {
const eventId = typeof event.event_id === "string" ? event.event_id.trim() : "";
let claimedInboundEvent = false;
let draftStreamRef: ReturnType<typeof createMatrixDraftStream> | undefined;
let draftStreamRef: MatrixDraftStreamHandle | undefined;
let draftConsumed = false;
try {
const eventType = event.type;
@@ -645,6 +671,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
: `matrix pairing reminder sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
);
try {
const { sendMessageMatrix } = await loadMatrixSendModule();
await sendMessageMatrix(
`room:${roomId}`,
created
@@ -712,6 +739,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
if (isReactionEvent) {
const senderName = await getSenderName();
const { handleInboundMatrixReaction } = await import("./reaction-events.js");
await handleInboundMatrixReaction({
client,
core,
@@ -957,6 +985,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
}
const senderName = await getSenderName();
if (_configuredBinding) {
const { ensureConfiguredAcpBindingReady } = await loadAcpBindingRuntime();
const ensured = await ensureConfiguredAcpBindingReady({
cfg,
configuredBinding: _configuredBinding,
@@ -972,6 +1001,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
}
}
if (_runtimeBindingId) {
const { getSessionBindingService } = await loadSessionBindingRuntime();
getSessionBindingService().touch(_runtimeBindingId, eventTs ?? undefined);
}
const preparedTrigger =
@@ -1270,17 +1300,23 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
}),
);
if (shouldAckReaction() && _messageId) {
reactMatrixMessage(roomId, _messageId, ackReaction, client).catch((err) => {
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
});
loadMatrixSendModule()
.then(({ reactMatrixMessage }) =>
reactMatrixMessage(roomId, _messageId, ackReaction, client),
)
.catch((err) => {
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
});
}
if (_messageId) {
sendReadReceiptMatrix(roomId, _messageId, client).catch((err) => {
logVerboseMessage(
`matrix: read receipt failed room=${roomId} id=${_messageId}: ${String(err)}`,
);
});
loadMatrixSendModule()
.then(({ sendReadReceiptMatrix }) => sendReadReceiptMatrix(roomId, _messageId, client))
.catch((err) => {
logVerboseMessage(
`matrix: read receipt failed room=${roomId} id=${_messageId}: ${String(err)}`,
);
});
}
const tableMode = core.channel.text.resolveMarkdownTableMode({
@@ -1299,8 +1335,14 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
accountId: _route.accountId,
});
const typingCallbacks = createTypingCallbacks({
start: () => sendTypingMatrix(roomId, true, undefined, client),
stop: () => sendTypingMatrix(roomId, false, undefined, client),
start: async () => {
const { sendTypingMatrix } = await loadMatrixSendModule();
await sendTypingMatrix(roomId, true, undefined, client);
},
stop: async () => {
const { sendTypingMatrix } = await loadMatrixSendModule();
await sendTypingMatrix(roomId, false, undefined, client);
},
onStartError: (err) => {
logTypingFailure({
log: logVerboseMessage,
@@ -1323,18 +1365,20 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const draftStreamingEnabled = streaming !== "off";
const quietDraftStreaming = streaming === "quiet";
const draftReplyToId = replyToMode !== "off" && !threadTarget ? _messageId : undefined;
const draftStream = draftStreamingEnabled
? createMatrixDraftStream({
roomId,
client,
cfg,
mode: quietDraftStreaming ? "quiet" : "partial",
threadId: threadTarget,
replyToId: draftReplyToId,
preserveReplyId: replyToMode === "all",
accountId: _route.accountId,
log: logVerboseMessage,
})
const draftStream: MatrixDraftStreamHandle | undefined = draftStreamingEnabled
? await import("../draft-stream.js").then(({ createMatrixDraftStream }) =>
createMatrixDraftStream({
roomId,
client,
cfg,
mode: quietDraftStreaming ? "quiet" : "partial",
threadId: threadTarget,
replyToId: draftReplyToId,
preserveReplyId: replyToMode === "all",
accountId: _route.accountId,
log: logVerboseMessage,
}),
)
: undefined;
draftStreamRef = draftStream;
type PendingDraftBoundary = {
@@ -1458,6 +1502,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const requiresFinalEdit =
quietDraftStreaming || !draftStream.matchesPreparedText(payload.text);
if (requiresFinalEdit) {
const { editMessageMatrix } = await loadMatrixSendModule();
await editMessageMatrix(roomId, draftEventId, payload.text, {
client,
cfg,
@@ -1500,6 +1545,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
quietDraftStreaming ||
(typeof payloadText === "string" && !payloadTextMatchesDraft);
if (textEditOk && payloadText && requiresFinalTextEdit) {
const { editMessageMatrix } = await loadMatrixSendModule();
textEditOk = await editMessageMatrix(roomId, draftEventId, payloadText, {
client,
cfg,
@@ -1568,6 +1614,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
// Re-assert typing so the user still sees the indicator while
// the next block generates.
const { sendTypingMatrix } = await loadMatrixSendModule();
await sendTypingMatrix(roomId, true, undefined, client).catch(() => {});
}
} else {

View File

@@ -390,12 +390,27 @@ describe("monitorMatrixProvider", () => {
({ monitorMatrixProvider } = await import("./index.js"));
});
async function flushUntil(predicate: () => boolean, message: string): Promise<void> {
for (let i = 0; i < 20; i++) {
if (predicate()) {
return;
}
await Promise.resolve();
}
throw new Error(message);
}
async function waitForCallOrderEntry(entry: string): Promise<void> {
await flushUntil(
() => hoisted.callOrder.includes(entry),
`expected call order to include ${entry}`,
);
}
async function startMonitorAndAbortAfterStartup(): Promise<void> {
const abortController = new AbortController();
const monitorPromise = monitorMatrixProvider({ abortSignal: abortController.signal });
await vi.waitFor(() => {
expect(hoisted.callOrder).toContain("start-client");
});
await waitForCallOrderEntry("start-client");
abortController.abort();
await monitorPromise;
}
@@ -471,9 +486,7 @@ describe("monitorMatrixProvider", () => {
setStatus: hoisted.setStatus,
});
await vi.waitFor(() => {
expect(hoisted.callOrder).toContain("start-client");
});
await waitForCallOrderEntry("start-client");
expect(hoisted.setStatus).toHaveBeenCalledWith(
expect.objectContaining({
@@ -486,16 +499,14 @@ describe("monitorMatrixProvider", () => {
hoisted.client.emit("sync.state", "SYNCING", "RECONNECTING", undefined);
await vi.waitFor(() => {
expect(hoisted.setStatus).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "default",
connected: true,
healthState: "healthy",
lastError: null,
}),
);
});
expect(hoisted.setStatus).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "default",
connected: true,
healthState: "healthy",
lastError: null,
}),
);
abortController.abort();
await expect(monitorPromise).resolves.toBeUndefined();
@@ -511,9 +522,7 @@ describe("monitorMatrixProvider", () => {
setStatus: hoisted.setStatus,
});
await vi.waitFor(() => {
expect(hoisted.callOrder).toContain("start-client");
});
await waitForCallOrderEntry("start-client");
const getHealthySyncSinceMs = hoisted.registeredHealthySyncGetter;
if (!getHealthySyncSinceMs) {
@@ -569,9 +578,7 @@ describe("monitorMatrixProvider", () => {
process.on("unhandledRejection", onUnhandled);
try {
const monitorPromise = monitorMatrixProvider({ abortSignal: abortController.signal });
await vi.waitFor(() => {
expect(hoisted.callOrder).toContain("start-client");
});
await waitForCallOrderEntry("start-client");
const onRoomMessage = hoisted.registeredOnRoomMessage;
if (!onRoomMessage) {
@@ -604,9 +611,7 @@ describe("monitorMatrixProvider", () => {
setStatus: hoisted.setStatus,
});
await vi.waitFor(() => {
expect(hoisted.callOrder).toContain("start-client");
});
await waitForCallOrderEntry("start-client");
hoisted.client.emit("sync.unexpected_error", new Error("sync exploded"));
@@ -696,9 +701,7 @@ describe("monitorMatrixProvider", () => {
const monitorPromise = monitorMatrixProvider({ abortSignal: abortController.signal });
await vi.waitFor(() => {
expect(hoisted.callOrder).toContain("start-client");
});
await waitForCallOrderEntry("start-client");
abortController.abort();
@@ -726,9 +729,10 @@ describe("monitorMatrixProvider", () => {
const monitorPromise = monitorMatrixProvider({ abortSignal: abortController.signal });
await vi.waitFor(() => {
expect(hoisted.runMatrixStartupMaintenance).toHaveBeenCalledTimes(1);
});
await flushUntil(
() => hoisted.runMatrixStartupMaintenance.mock.calls.length === 1,
"expected startup maintenance to run",
);
abortController.abort();
@@ -767,10 +771,8 @@ describe("monitorMatrixProvider", () => {
const abortController = new AbortController();
const monitorPromise = monitorMatrixProvider({ abortSignal: abortController.signal });
await vi.waitFor(() => {
expect(hoisted.callOrder).toContain("start-client");
expect(hoisted.backfillMatrixAuthDeviceIdAfterStartup).toHaveBeenCalledTimes(1);
});
await waitForCallOrderEntry("start-client");
expect(hoisted.backfillMatrixAuthDeviceIdAfterStartup).toHaveBeenCalledTimes(1);
expect(hoisted.backfillMatrixAuthDeviceIdAfterStartup).toHaveBeenCalledWith(
expect.objectContaining({
abortSignal: abortController.signal,
@@ -837,9 +839,7 @@ describe("monitorMatrixProvider", () => {
});
const monitorPromise = monitorMatrixProvider({ abortSignal: abortController.signal });
await vi.waitFor(() => {
expect(hoisted.callOrder).toContain("start-client");
});
await waitForCallOrderEntry("start-client");
const onRoomMessage = hoisted.registeredOnRoomMessage;
if (!onRoomMessage) {
throw new Error("expected room message handler to be registered");
@@ -847,9 +847,7 @@ describe("monitorMatrixProvider", () => {
const roomMessagePromise = onRoomMessage("!room:example.org", { event_id: "$event" });
abortController.abort();
await vi.waitFor(() => {
expect(hoisted.callOrder).toContain("pause-client");
});
await waitForCallOrderEntry("pause-client");
expect(hoisted.callOrder).not.toContain("stop-deduper");
if (resolveHandler === null) {

View File

@@ -1,7 +1,7 @@
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
} from "openclaw/plugin-sdk/string-coerce-runtime";
import type { LocationMessageEventContent } from "../sdk.js";
import { formatLocationText, toLocationContext, type NormalizedLocation } from "./runtime-api.js";
import { EventType } from "./types.js";

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