Compare commits

..

116 Commits

Author SHA1 Message Date
Vincent Koc
c03f97f954 test(plugins): break google contract helper cycles 2026-04-17 14:25:21 -07:00
Vincent Koc
8b5030447a test(plugins): trim contract helper runtime boot 2026-04-17 14:25:21 -07:00
Vincent Koc
48c4a026dd test(plugins): fast-path bundled provider contract loads 2026-04-17 14:25:21 -07:00
Vincent Koc
420b1da82f test(plugins): trim tts summarization contract boot 2026-04-17 14:25:21 -07:00
Vincent Koc
afdbf48914 test(plugins): fast-path bundled setup web providers 2026-04-17 14:25:21 -07:00
Vincent Koc
c0b8250f4f test(plugins): trim contract registry runtime fanout 2026-04-17 14:25:21 -07:00
Vincent Koc
d89cee8787 test(plugins): avoid runtime loads for id-only registry checks 2026-04-17 14:25:21 -07:00
Vincent Koc
815e2fc529 test(plugins): trim tts contract mock startup 2026-04-17 14:25:21 -07:00
Vincent Koc
18b45e63f2 test(plugins): speed up tts contract helper boot 2026-04-17 14:25:21 -07:00
Vincent Koc
855c7cf989 test(plugins): keep loader contracts inventory-backed 2026-04-17 14:25:21 -07:00
Vincent Koc
78f0fb660c test(plugins): avoid per-test discovery reloads 2026-04-17 14:25:21 -07:00
Vincent Koc
30895f7135 fix(auth): restore cli bootstrap split on rebase 2026-04-17 14:19:45 -07:00
Vincent Koc
76812401ca test(auth): align cli overlay coverage after rebase 2026-04-17 14:14:03 -07:00
Vincent Koc
5edf876a5e test(auth): add codex oauth red-blue coverage 2026-04-17 14:14:03 -07:00
Vincent Koc
1e7c7dd02f refactor(auth): polish external oauth bootstrap flow 2026-04-17 14:11:41 -07:00
Vincent Koc
f61712437f refactor(auth): tighten external oauth bootstrap policy 2026-04-17 14:05:26 -07:00
Agustin Rivera
99ef3a63c5 fix(gateway): require read scope for assistant media (#68175)
* fix(gateway): enforce assistant media scopes

* changelog: require read scope for assistant media (#68175)

* skip scope enforcement for auth.mode=none

Exclude method "none" from the identity-bearing scope gate so
gateway.auth.mode=none deployments are not regressed by the new
operator.read check.

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-17 15:03:53 -06:00
Peter Steinberger
af0f7e1bc7 test: type runtime auth overlay mock 2026-04-17 21:56:25 +01:00
Peter Steinberger
8742e8fae3 test: stub channel migration setup surfaces 2026-04-17 21:53:25 +01:00
Peter Steinberger
8dde0acbae test: trim agent test hot spots 2026-04-17 21:53:08 +01:00
Vincent Koc
ff55cd5c16 refactor(auth): drop legacy external cli oauth sync path 2026-04-17 13:52:37 -07:00
Devin Robison
0e7a992d3f fix(agents): filter bundled tools through final policy (#68195)
* fix(agents): filter bundled tools through final policy

* changelog: filter bundled tools through final policy (#68195)

* forward agentId into compaction tool-policy filter

Pass effectiveSkillAgentId to applyFinalEffectiveToolPolicy in the
compaction path so per-agent tool policies apply to bundled tools
during compaction the same way they do during normal runs.

* scope final tool-policy filter to bundled tools only

Running the full tool-policy pipeline on the merged core + bundled tool list
re-filters core tools whose plugin WeakMap metadata no longer survives the
normalize/hook wrappers applied by createOpenClawCodingTools(). Narrow the
helper to only the newly-appended bundled MCP/LSP tools so plugin-provided
core tools keep matching group:plugins and plugin-id allowlist entries.

* harden authorization signals on final tool policy

- message.action gateway handler now server-derives senderIsOwner from the
  authenticated gateway client scopes (ADMIN_SCOPE on client.connect.scopes)
  and ignores any senderIsOwner value on the wire, so a non-admin scoped
  caller cannot spoof owner status to unlock owner-only channel actions or
  owner-only tool policy. Schema keeps the field optional for wire compat
  but documents that it is ignored.

- applyFinalEffectiveToolPolicy now cross-checks caller-provided groupId
  against the session-derived group context resolved from sessionKey (and
  spawnedBy). When they disagree, the caller groupId plus its adjacent
  groupChannel/groupSpace are dropped and a warn is emitted, so a caller
  that fabricates a different group id cannot reach a more permissive
  group-scoped tool policy during the final bundled-tool filter. Added a
  JSDoc trust invariant on the helper input describing the required
  server-verified identity contract.

* align compact agentId resolution with core tools

Drop the explicit agentId on applyFinalEffectiveToolPolicy during
compaction. The core tool set produced just above via
createOpenClawCodingTools(...) also omits agentId, so resolveEffectiveToolPolicy
falls back to resolveAgentIdFromSessionKey(sessionKey) in both places.
Passing effectiveSkillAgentId only to the final filter made the two
policy lookups diverge on legacy/non-agent session keys where the
sessionKey path resolves to main but effectiveSkillAgentId follows the
configured default-agent path, which could deny or allow bundled tools
under a different per-agent policy than the already-created core tools.

* tighten trusted propagation for owner and group signals

- message.action gateway handler: full-operator callers (shared-secret
  bearer or operator.admin scope) now propagate the request-provided
  senderIsOwner through to channel action handlers instead of having it
  hard-coded off. Previously the hardened path force-derived ownership
  from ADMIN_SCOPE alone, which broke owner-gated actions when the
  trusted runtime forwards them via the least-privilege gateway path
  (callGatewayLeastPrivilege requests only the method scope, so even
  legitimate owner senders were downgraded to senderIsOwner=false).
  Narrowly-scoped callers (e.g. operator.write-only) still have the wire
  value forced to false so a non-admin caller cannot assert ownership.

- applyFinalEffectiveToolPolicy: fail-closed when the session key and
  spawnedBy encode no group context. Previously the helper only dropped
  a caller-provided groupId that conflicted with a non-empty set of
  session-derived group ids, which left an accept-caller fallback open
  when the session had no group context at all (direct/cron/subagent
  session keys). An attacker who could run without a group-bound session
  could then supply an arbitrary groupId and reach a more permissive
  group-scoped tool policy. Now: no session-derived group context plus
  any caller-provided groupId drops the caller value and warns.

* suppress unavailable-core-tool warnings in bundled-only pass

applyToolPolicyPipeline infers its coreToolNames reference set from the
tools array it is filtering. The bundled-only second pass only sees the
MCP/LSP subset, so normal core allowlist entries (for example
tools.allow: ['read', 'exec']) would look "unknown" during this pass
and emit misleading warnings even when the config is valid for the full
effective tool set — polluting logs and potentially evicting real
diagnostics from the shared warning cache. Set
suppressUnavailableCoreToolWarning on every step of this pass so known
core-tool allowlist entries stay silent; genuinely unknown entries
still surface through the otherEntries warning path.
2026-04-17 14:45:12 -06:00
Gustavo Madeira Santana
77e588ebc3 test: avoid bundled session normalizer fallback
Keep explicit session-key normalization on loaded channel plugins so
unknown provider contexts pass through without cold-loading bundled channel
runtimes. This preserves active plugin behavior and removes the slow
unknown-provider test path.
2026-04-17 16:41:46 -04:00
Gustavo Madeira Santana
5ae059db16 test: speed legacy state migration discovery
Keep bundled legacy migration discovery on narrow setup-entry surfaces so
state-migration tests and doctor cold paths avoid unrelated channel runtime
loads. Add targeted setup feature metadata, narrow Telegram/WhatsApp legacy
contracts, and a path-only pairing SDK helper.
2026-04-17 16:41:43 -04:00
Vincent Koc
a8a701291b refactor(auth): drop persisted external oauth ownership metadata 2026-04-17 13:28:54 -07:00
Vincent Koc
2c7c06c9b3 docs(changelog): note runtime-only external oauth import 2026-04-17 13:28:54 -07:00
Altay
d0cf6731aa fix(failover): classify INTERNAL 500 responses as retryable timeouts (#68238)
* Agents: treat Google INTERNAL 500 as timeout failover

(cherry picked from commit c2538523a22d39b65c6b4056ab4857ee84f06887)

* test(failover): narrow INTERNAL timeout patterns

* fix: document INTERNAL timeout retry guard

* fix: ignore plain status prose in server error classification

* fix(failover): preserve mixed server-error retry signals

* test(failover): dedupe internal status samples

* fix(failover): retry status prose with code 500

* fix: classify INTERNAL 500 responses as retryable timeouts

* fix: classify INTERNAL 500 responses as retryable timeouts

---------

Co-authored-by: Kosbling <github@kosbling.com>
Co-authored-by: Openbling <github@openbling.ai>
2026-04-17 23:24:26 +03:00
Vincent Koc
a001b5343f refactor(auth): make external cli oauth runtime-only 2026-04-17 13:14:17 -07:00
Gustavo Madeira Santana
50e71daaa0 test: keep inbound group policy tests hermetic 2026-04-17 15:50:41 -04:00
Gustavo Madeira Santana
8b76bcba90 test: avoid real Telegram config writes in retry tests 2026-04-17 15:50:41 -04:00
Gustavo Madeira Santana
c550642cde test: keep command registry native overrides hermetic 2026-04-17 15:50:39 -04:00
Peter Steinberger
fde25bfb8c test: isolate browser facade cache tests 2026-04-17 20:35:23 +01:00
Peter Steinberger
08e1eb7a9f test: narrow system run dispatch matrix 2026-04-17 20:27:52 +01:00
Peter Steinberger
c408bbe9c9 perf: cache browser plugin sdk facades 2026-04-17 20:26:14 +01:00
Peter Steinberger
809f42eeea test: trim UI and entry test overhead 2026-04-17 20:23:07 +01:00
Peter Steinberger
087f1584df test: streamline system run hotspot coverage 2026-04-17 20:18:01 +01:00
bwjoke
f7422e1fbc fix(failover): detect bare leading 402 assistant errors (#47579)
Merged via squash.

Prepared head SHA: ff336a0d97
Co-authored-by: bwjoke <1284814+bwjoke@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-04-17 22:06:55 +03:00
Peter Steinberger
169b68d709 test: narrow chat avatar fallback 2026-04-17 20:04:30 +01:00
Peter Steinberger
014eaa8492 test: merge env rejection invoke cases 2026-04-17 20:03:35 +01:00
Peter Steinberger
e9d052d728 test: merge shell payload plan checks 2026-04-17 20:01:58 +01:00
Peter Steinberger
f897025d9b test: narrow chat attachment rendering 2026-04-17 20:00:46 +01:00
Peter Steinberger
5f075d3d49 test: reuse session file fixture root 2026-04-17 19:57:19 +01:00
Gustavo Madeira Santana
8747351383 Media: keep inbound roots on media contracts 2026-04-17 14:56:47 -04:00
Peter Steinberger
bb5d9948c2 test: mock side result markdown 2026-04-17 19:56:17 +01:00
Peter Steinberger
be6dbd4084 test: mock chat sidebar markdown 2026-04-17 19:55:39 +01:00
Peter Steinberger
bbbb57f7f8 test: source install version helper only 2026-04-17 19:52:33 +01:00
Peter Steinberger
4dd999274b test: merge chat helper render tests 2026-04-17 19:51:43 +01:00
Peter Steinberger
7c862da6a1 test: split chat helper coverage 2026-04-17 19:50:39 +01:00
Peter Steinberger
125b1e0e20 test: reuse node-host runtime bins 2026-04-17 19:47:43 +01:00
Peter Steinberger
55c7776364 test: simplify acp and install test seams 2026-04-17 19:46:40 +01:00
Peter Steinberger
16e7f04a43 test: avoid login shell in install version test 2026-04-17 19:45:51 +01:00
Peter Steinberger
2e2f927d5d test: mock proxy capture store 2026-04-17 19:45:06 +01:00
Peter Steinberger
0a38098248 test: mock tts facade explicitly 2026-04-17 19:44:02 +01:00
Devin Robison
f61896b03c fix(cron): preserve untrusted awareness event labels (#68210)
* fix(cron): preserve untrusted awareness event labels

Keep isolated cron awareness summaries untrusted when they are promoted into the main session, and forward explicit trust downgrades through the gateway cron wrapper. Add focused regression coverage for both paths.

* changelog: note cron awareness untrusted-label preservation (#68210)
2026-04-17 12:43:48 -06:00
Peter Steinberger
2745e5b3bd test: narrow canvas and context hotspots 2026-04-17 19:42:59 +01:00
Gustavo Madeira Santana
f70b651b12 Tests: avoid media registry load for duplicate ids 2026-04-17 14:41:18 -04:00
Peter Steinberger
dadcfb574f test: trim surrogate chunk fixtures 2026-04-17 19:38:53 +01:00
Peter Steinberger
729feb4b99 test: reuse exec approval home fixture 2026-04-17 19:37:47 +01:00
Peter Steinberger
8c3a8f0b1b test: shrink context registry chunk coverage 2026-04-17 19:35:55 +01:00
Gustavo Madeira Santana
ee0c8177bf Fix canvas host header test type 2026-04-17 14:35:36 -04:00
Gustavo Madeira Santana
462074c4c2 Fix check type errors 2026-04-17 14:35:36 -04:00
Peter Steinberger
c0a9b694f3 test: reuse node host home fixture 2026-04-17 19:35:19 +01:00
Peter Steinberger
2c43c441b2 test: source minimal install helper fixture 2026-04-17 19:34:01 +01:00
Peter Steinberger
b39f3cf266 test: avoid polling settled acp reconnect 2026-04-17 19:31:40 +01:00
Peter Steinberger
79dfb4db69 test: shorten routing cache scalability case 2026-04-17 19:30:36 +01:00
Peter Steinberger
990bd81726 test: avoid canvas host socket setup 2026-04-17 19:29:42 +01:00
Devin Robison
90979d7c3e fix(feishu): resolve card-action chat type before dispatch (#68201)
* fix(feishu): resolve card-action chat type before dispatch

* changelog: resolve card-action chat type before dispatch (#68201)

* address review: prefer chat_mode over chat_type, add error-path tests

- Swap resolution order to check chat_mode (conversation type) before
  chat_type (privacy classification), since Feishu's chat_type can
  return "private" for private group chats which would be wrongly
  classified as p2p.
- Treat "topic" as group semantics in the normalizer.
- Add comment explaining the field semantics and why "private" maps
  to "p2p" (safe-failure direction).
- Add two error-path tests: API returns non-zero code, and API throws.

* map chat_type=public to group in normalizer

Feishu's chat_type can return "public" for public group chats.
Without this mapping the fallback resolver would miss it and default
to p2p, routing a group card action through DM handling.

* address Aisle: cache chat-type lookups and scrub log output

- Add a 30-minute TTL cache for chatId -> chatType so repeated card
  actions on the same chat skip the Feishu API call.
- Strip chatId, event.token, and raw error strings from log messages;
  use err.message instead of String(err) to avoid leaking stack traces
  or HTTP internals from the Feishu SDK.

* prune expired chat-type cache entries

Add pruneChatTypeCache() called on each lookup so expired entries are
evicted and the cache stays bounded in long-running processes.

* address Aisle: scope cache by account, cap size, sanitize logs

- Key cache by accountId:chatId to prevent cross-account contamination.
- Cap cache at 5000 entries and evict oldest when exceeded.
- Sanitize response.msg and err.message with CR/LF stripping and
  length cap before logging to prevent log injection.
2026-04-17 12:29:04 -06:00
Peter Steinberger
8eb577b361 test: slim routing cache rollover coverage 2026-04-17 19:27:52 +01:00
Peter Steinberger
7edce9c8fa test: reuse inline eval fixtures 2026-04-17 19:25:58 +01:00
Peter Steinberger
e75cd46ba6 test: isolate plugin tools mcp handlers 2026-04-17 19:25:20 +01:00
Peter Steinberger
38923d13a6 test: trim boundary and fixture hotspots 2026-04-17 19:22:38 +01:00
Peter Steinberger
b303b6c492 test: streamline navigation browser checks 2026-04-17 19:17:07 +01:00
Peter Steinberger
b6e55bf819 test: combine config and skill render checks 2026-04-17 19:13:44 +01:00
Peter Steinberger
c47c4b3574 test: trim remaining ui browser cases 2026-04-17 19:11:58 +01:00
Peter Steinberger
d155d578eb test: merge more ui render hotspots 2026-04-17 19:10:22 +01:00
Gustavo Madeira Santana
3a1e469732 QA: track scenario coverage intent 2026-04-17 14:05:49 -04:00
Gustavo Madeira Santana
f334ca2b50 Auto-reply: fast-path sandbox media root resolution 2026-04-17 14:05:49 -04:00
Peter Steinberger
e606656b56 test: merge remaining small render checks 2026-04-17 19:02:05 +01:00
Peter Steinberger
9feeb921f5 test: trim config form search render cases 2026-04-17 19:00:57 +01:00
Peter Steinberger
c050cdaa96 test: merge small view render cases 2026-04-17 18:59:08 +01:00
Peter Steinberger
783bb1f759 test: move query token checks to settings unit 2026-04-17 18:57:15 +01:00
Peter Steinberger
4ba12bd134 test: trim duplicated navigation auth cases 2026-04-17 18:55:35 +01:00
Peter Steinberger
f0c6b102be test: trim duplicate navigation and cron cases 2026-04-17 18:54:46 +01:00
Peter Steinberger
354dbf2161 test: reset config view state directly 2026-04-17 18:50:18 +01:00
Peter Steinberger
1a3a040cc3 test: move chat markdown sidebar to direct render 2026-04-17 18:49:15 +01:00
Gustavo Madeira Santana
6184f17c91 Twitch: add bundled setup entry (#68008)
Merged via squash.

Prepared head SHA: 59305356a0
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-17 13:49:08 -04:00
Peter Steinberger
bbac7773ff test: fold chat context browser check 2026-04-17 18:47:00 +01:00
Peter Steinberger
aaf9064a75 test: move chat image safety to direct render 2026-04-17 18:46:16 +01:00
Peter Steinberger
36b98f78b2 test: merge navigation singleton browser checks 2026-04-17 18:45:34 +01:00
Peter Steinberger
ddd2c2a602 test: merge chat side-result checks 2026-04-17 18:42:03 +01:00
Peter Steinberger
f7eb746081 test: merge cron history checks 2026-04-17 18:41:16 +01:00
Peter Steinberger
c2e4b47d7b test: merge responsive navigation shell checks 2026-04-17 18:40:07 +01:00
Vincent Koc
628e6cd446 docs(changelog): add codex oauth fixes 2026-04-17 10:39:21 -07:00
Peter Steinberger
5d8cecbe7d test: merge navigation routing cases 2026-04-17 18:39:00 +01:00
Gustavo Madeira Santana
2b08233a3e Tests: mock channel registry bundled fallback
Keep the registry fallback unit test on a minimal bundled fixture instead of loading the real Google Chat plugin. Doctor capability metadata remains covered by the doctor channel capability tests.
2026-04-17 13:38:24 -04:00
Gustavo Madeira Santana
a464f5926b Secrets: avoid broad web search discovery for single plugin config
Add an Exa web-search contract artifact and use single bundled plugin-scoped webSearch config as a provider hint. This keeps runtime secret resolution on metadata-only surfaces instead of importing full provider tool implementations.
2026-04-17 13:38:24 -04:00
Peter Steinberger
20cf51169b test: merge config view browser checks 2026-04-17 18:37:53 +01:00
Vincent Koc
eed71160ae fix(status): align oauth health with runtime 2026-04-17 10:36:51 -07:00
Peter Steinberger
5c2f4afcce test: merge chat context notice checks 2026-04-17 18:36:33 +01:00
Peter Steinberger
1df50183b2 test: merge chat image safety cases 2026-04-17 18:35:52 +01:00
Peter Steinberger
0747a9c85a test(discord): isolate debug proxy env 2026-04-17 18:35:06 +01:00
Peter Steinberger
7876e3e736 test(cron): remove fast retry timer dependency 2026-04-17 18:35:06 +01:00
Peter Steinberger
1519b006b8 test(auth): isolate provider alias registry mock 2026-04-17 18:35:06 +01:00
Peter Steinberger
f93b7da4c4 test: merge chat attachment cases 2026-04-17 18:34:19 +01:00
Gustavo Madeira Santana
9bcf8f8243 Configure: defer channel status until selection (#68007)
Merged via squash.

Prepared head SHA: 24cafcd5fe
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-17 13:34:13 -04:00
Peter Steinberger
474b08bfbd test: merge dreaming view UI cases 2026-04-17 18:30:08 +01:00
Gustavo Madeira Santana
00fadb978f Tests: narrow bundled shape guard source check 2026-04-17 13:28:49 -04:00
Peter Steinberger
f665c767e6 test: drop duplicate markdown cases 2026-04-17 18:28:40 +01:00
Peter Steinberger
81d6cf9c82 test: merge cron view UI cases 2026-04-17 18:25:20 +01:00
Vincent Koc
f513bae67e fix(oauth): make codex tls preflight advisory 2026-04-17 10:24:00 -07:00
Peter Steinberger
76c8db3766 test: merge chat view UI cases 2026-04-17 18:23:24 +01:00
Vincent Koc
3ed0995fa9 fix(auth): keep codex oauth canonical in openclaw 2026-04-17 10:20:43 -07:00
Peter Steinberger
df06343dfa test: merge mobile navigation UI checks 2026-04-17 18:19:37 +01:00
Peter Steinberger
8448569aca test: narrow skills config imports 2026-04-17 18:16:43 +01:00
Devin Robison
114b87caf2 fix(macos): require trusted SSH host keys (#68199)
* fix(macos): require trusted SSH host keys

* chore(changelog): add macOS SSH strict host-key entry
2026-04-17 11:11:10 -06:00
Peter Steinberger
dfca5bd0fe test: isolate oauth refresh queue mocks 2026-04-17 18:10:07 +01:00
258 changed files with 8373 additions and 5181 deletions

View File

@@ -36,7 +36,6 @@ jobs:
run_windows: ${{ steps.manifest.outputs.run_windows }}
has_changed_extensions: ${{ steps.manifest.outputs.has_changed_extensions }}
changed_extensions_matrix: ${{ steps.manifest.outputs.changed_extensions_matrix }}
changed_paths_json: ${{ steps.manifest.outputs.changed_paths_json }}
run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }}
run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }}
checks_fast_core_matrix: ${{ steps.manifest.outputs.checks_fast_core_matrix }}
@@ -109,16 +108,8 @@ jobs:
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
import {
listChangedExtensionIds,
listChangedPathsForScope,
} from "./scripts/lib/changed-extensions.mjs";
import { listChangedExtensionIds } from "./scripts/lib/changed-extensions.mjs";
const changedPaths = listChangedPathsForScope({
base: process.env.BASE_SHA,
head: "HEAD",
fallbackBaseRef: process.env.BASE_REF,
});
const extensionIds = listChangedExtensionIds({
base: process.env.BASE_SHA,
head: "HEAD",
@@ -126,11 +117,9 @@ jobs:
unavailableBaseBehavior: "all",
});
const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });
const changedPathsJson = JSON.stringify(changedPaths);
appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
appendFileSync(process.env.GITHUB_OUTPUT, `changed_paths_json=${changedPathsJson}\n`, "utf8");
EOF
- name: Build CI manifest
@@ -146,7 +135,6 @@ jobs:
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
OPENCLAW_CI_CHANGED_PATHS_JSON: ${{ steps.changed_extensions.outputs.changed_paths_json || '[]' }}
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
@@ -215,7 +203,6 @@ jobs:
run_windows: runWindows,
has_changed_extensions: hasChangedExtensions,
changed_extensions_matrix: changedExtensionsMatrix,
changed_paths_json: process.env.OPENCLAW_CI_CHANGED_PATHS_JSON ?? "[]",
run_build_artifacts: runNode,
run_checks_fast: runNode,
checks_fast_core_matrix: createMatrix(
@@ -969,8 +956,6 @@ jobs:
continue-on-error: true
env:
OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 4
OPENCLAW_EXTENSION_BOUNDARY_CHANGED_EXTENSIONS_MATRIX: ${{ needs.preflight.outputs.changed_extensions_matrix }}
OPENCLAW_EXTENSION_BOUNDARY_CHANGED_PATHS_JSON: ${{ needs.preflight.outputs.changed_paths_json }}
run: pnpm run test:extensions:package-boundary
- name: Enforce safe external URL opening policy

View File

@@ -25,6 +25,22 @@ Docs: https://docs.openclaw.ai
- 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.
- macOS/remote SSH: require an already-trusted host key on the macOS remote command, gateway probe, port tunnel, and pairing probe paths by switching `StrictHostKeyChecking=accept-new` to `StrictHostKeyChecking=yes` and centralizing the shared SSH option fragments in `CommandResolver`, so first-time macOS remote connections no longer silently accept an unknown host key and must be trusted ahead of time via `~/.ssh/known_hosts`. (#68199)
- CLI/configure: show the channel picker before probing statuses and let remove mode delete configured channel blocks directly from config. (#68007) Thanks @gumadeiras.
- OpenAI Codex/OAuth: keep OpenClaw as the canonical owner for imported Codex CLI OAuth sessions, stop writing refreshed credentials back into `.codex`, and prefer fresher OpenClaw credentials over stale imported CLI state so refresh recovery stays stable. Thanks @vincentkoc.
- OpenAI Codex/OAuth: treat the OpenAI TLS prerequisites probe as advisory instead of a hard blocker, so Codex sign-in can still proceed when the speculative Node/OpenSSL precheck fails but the real OAuth flow still works. Thanks @vincentkoc.
- Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc.
- OpenAI Codex/OAuth: keep external CLI OAuth imports runtime-only by overlaying fresher Codex CLI credentials without mutating `auth-profiles.json`, so `.codex` stays a bootstrap/runtime input instead of becoming durable OpenClaw state. Thanks @vincentkoc.
- OpenAI Codex/OAuth: drop legacy CLI-manager routing from the remaining bootstrap path so Codex and MiniMax CLI imports are matched by their canonical OpenClaw profile ids instead of stale `managedBy` metadata. Thanks @vincentkoc.
- OpenAI Codex/OAuth: only bootstrap from external CLI OAuth when the local OpenClaw profile is missing or unusable, so healthy local sessions are no longer overridden by fresher `.codex` tokens. Thanks @vincentkoc.
- OpenAI Codex/OAuth: rename the external CLI bootstrap helper, reuse the same usable-oauth check across runtime fallback paths, and add debug logs plus health coverage so bootstrap decisions stay legible. Thanks @vincentkoc.
- Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras.
- Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201)
- Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210)
- Agents/fallback: recognize bare leading ZenMux `402 ...` quota-refresh errors without misclassifying plain numeric `402 ...` text, and keep the embedded fallback regression coverage stable. (#47579) Thanks @bwjoke.
- Failover/google: only treat `INTERNAL` status payloads as retryable timeouts when they also carry a `500` code, so malformed non-500 payloads do not enter the retry path. (#68238) Thanks @altaywtf and @Openbling.
- Agents/tools: filter bundled MCP/LSP tools through the final owner-only and tool-policy pipeline after merging them into the effective tool list, so existing allowlists, deny rules, sandbox policy, subagent policy, and owner-only restrictions apply to bundled tools the same way they apply to core tools. (#68195)
- Gateway/assistant media: require `operator.read` scope for assistant-media file and metadata requests on identity-bearing HTTP auth paths so callers without a read scope can no longer access assistant media. (#68175) Thanks @eleqtrizit.
## 2026.4.15

View File

@@ -3,6 +3,12 @@ import Foundation
enum CommandResolver {
private static let projectRootDefaultsKey = "openclaw.gatewayProjectRootPath"
private static let helperName = "openclaw"
static let strictHostKeyCheckingSSHOptions = [
"-o", "StrictHostKeyChecking=yes",
]
static let updateHostKeysSSHOptions = [
"-o", "UpdateHostKeys=yes",
]
static func gatewayEntrypoint(in root: URL) -> String? {
let distEntry = root.appendingPathComponent("dist/index.js").path
@@ -397,9 +403,7 @@ enum CommandResolver {
"""
let options: [String] = [
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
] + self.strictHostKeyCheckingSSHOptions + self.updateHostKeysSSHOptions
let args = self.sshArguments(
target: parsed,
identity: settings.identity,

View File

@@ -483,8 +483,7 @@ final class NodePairingApprovalPrompter {
"-o", "ConnectTimeout=5",
"-o", "NumberOfPasswordPrompts=0",
"-o", "PreferredAuthentications=publickey",
"-o", "StrictHostKeyChecking=accept-new",
]
] + CommandResolver.strictHostKeyCheckingSSHOptions
guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else {
return false
}

View File

@@ -200,9 +200,7 @@ enum RemoteGatewayProbe {
let options = [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
] + CommandResolver.strictHostKeyCheckingSSHOptions + CommandResolver.updateHostKeysSSHOptions
let args = CommandResolver.sshArguments(
target: parsed,
identity: identity,

View File

@@ -73,14 +73,12 @@ final class RemotePortTunnel {
let options: [String] = [
"-o", "BatchMode=yes",
"-o", "ExitOnForwardFailure=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
"-o", "ServerAliveInterval=15",
"-o", "ServerAliveCountMax=3",
"-o", "TCPKeepAlive=yes",
"-N",
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
]
] + CommandResolver.strictHostKeyCheckingSSHOptions + CommandResolver.updateHostKeysSSHOptions
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
let args = CommandResolver.sshArguments(
target: parsed,

View File

@@ -164,6 +164,9 @@ import Testing
} else {
#expect(Bool(false))
}
#expect(cmd.contains("StrictHostKeyChecking=yes"))
#expect(!cmd.contains("StrictHostKeyChecking=accept-new"))
#expect(cmd.contains("UpdateHostKeys=yes"))
#expect(cmd.contains("-i"))
#expect(cmd.contains("/tmp/id_ed25519"))
if let script = cmd.last {

View File

@@ -1,2 +1,2 @@
e3df4c13b4dcdc07809775c56eed15c3ab924db191a08fb5a7b48d6f73001966 plugin-sdk-api-baseline.json
2bb30ad45d5b382e92fd6b8a240a47f7679c59f9b524e54420879fadc28264b8 plugin-sdk-api-baseline.jsonl
052943a9f1eb82a49452b6715f4c08faeb650d16a36c150a3c726ff392ecad0d plugin-sdk-api-baseline.json
a5077395f009f5064331dc1c38bb2d6d2864299d3c1fbd9e40956c1700fa253c plugin-sdk-api-baseline.jsonl

View File

@@ -1,4 +1,5 @@
export { CLAUDE_CLI_BACKEND_ID, isClaudeCliProvider } from "./cli-shared.js";
export { buildAnthropicProvider } from "./register.runtime.js";
export {
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,

View File

@@ -18,7 +18,10 @@ import {
upsertAuthProfile,
validateAnthropicSetupToken,
} from "openclaw/plugin-sdk/provider-auth";
import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared";
import {
cloneFirstTemplateModel,
type ProviderPlugin,
} from "openclaw/plugin-sdk/provider-model-shared";
import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import * as claudeCliAuth from "./cli-auth-seam.js";
@@ -395,11 +398,10 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
};
}
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
export function buildAnthropicProvider(): ProviderPlugin {
const providerId = "anthropic";
const defaultAnthropicModel = DEFAULT_ANTHROPIC_MODEL;
api.registerCliBackend(buildAnthropicCliBackend());
api.registerProvider({
return {
id: providerId,
label: "Anthropic",
docsPath: "/providers/models",
@@ -505,6 +507,11 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
store: ctx.store,
profileId: ctx.profileId,
}),
});
};
}
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
api.registerCliBackend(buildAnthropicCliBackend());
api.registerProvider(buildAnthropicProvider());
api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider);
}

View File

@@ -1,4 +1,4 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const {
GatewayIntents,
@@ -221,6 +221,9 @@ describe("createDiscordGatewayPlugin", () => {
}
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv("OPENCLAW_DEBUG_PROXY_ENABLED", "");
vi.stubEnv("OPENCLAW_DEBUG_PROXY_URL", "");
vi.stubGlobal("fetch", globalFetchMock);
vi.useRealTimers();
baseRegisterClientSpy.mockClear();
@@ -236,6 +239,11 @@ describe("createDiscordGatewayPlugin", () => {
resetLastAgent();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
it("uses safe gateway metadata lookup without proxy", async () => {
const runtime = createRuntime();
const plugin = createDiscordGatewayPlugin({

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { createDiscordPluginBase } from "./shared.js";
describe("createDiscordPluginBase", () => {
it("owns Discord native command name overrides", () => {
const plugin = createDiscordPluginBase({ setup: {} as never });
expect(
plugin.commands?.resolveNativeCommandName?.({
commandKey: "tts",
defaultName: "tts",
}),
).toBe("voice");
expect(
plugin.commands?.resolveNativeCommandName?.({
commandKey: "status",
defaultName: "status",
}),
).toBe("status");
});
});

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { createExaWebSearchProvider as createContractExaWebSearchProvider } from "../web-search-contract-api.js";
import { __testing, createExaWebSearchProvider } from "./exa-web-search-provider.js";
describe("exa web search provider", () => {
@@ -15,6 +16,31 @@ describe("exa web search provider", () => {
expect(applied.plugins?.entries?.exa?.enabled).toBe(true);
});
it("keeps the lightweight contract surface aligned with provider metadata", () => {
const provider = createExaWebSearchProvider();
const contractProvider = createContractExaWebSearchProvider();
if (!contractProvider.applySelectionConfig) {
throw new Error("Expected contract applySelectionConfig to be defined");
}
const applied = contractProvider.applySelectionConfig({});
expect(contractProvider).toMatchObject({
id: provider.id,
label: provider.label,
hint: provider.hint,
onboardingScopes: provider.onboardingScopes,
credentialLabel: provider.credentialLabel,
envVars: provider.envVars,
placeholder: provider.placeholder,
signupUrl: provider.signupUrl,
docsUrl: provider.docsUrl,
autoDetectOrder: provider.autoDetectOrder,
credentialPath: provider.credentialPath,
});
expect(contractProvider.createTool({ config: {}, searchConfig: {} })).toBeNull();
expect(applied.plugins?.entries?.exa?.enabled).toBe(true);
});
it("prefers scoped configured api keys over environment fallbacks", () => {
expect(__testing.resolveExaApiKey({ apiKey: "exa-secret" })).toBe("exa-secret");
});

View File

@@ -0,0 +1,29 @@
import {
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-contract";
export function createExaWebSearchProvider(): WebSearchProviderPlugin {
const credentialPath = "plugins.entries.exa.config.webSearch.apiKey";
return {
id: "exa",
label: "Exa Search",
hint: "Neural + keyword search with date filters and content extraction",
onboardingScopes: ["text-inference"],
credentialLabel: "Exa API key",
envVars: ["EXA_API_KEY"],
placeholder: "exa-...",
signupUrl: "https://exa.ai/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 65,
credentialPath,
...createWebSearchProviderContractFields({
credentialPath,
searchCredential: { type: "scoped", scopeId: "exa" },
configuredCredential: { pluginId: "exa" },
selectionPluginId: "exa",
}),
createTool: () => null,
};
}

View File

@@ -25,9 +25,14 @@ vi.mock("./bot.js", () => ({
handleFeishuMessage: vi.fn(),
}));
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const sendCardFeishuMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
vi.mock("./send.js", () => ({
sendCardFeishu: sendCardFeishuMock,
sendMessageFeishu: sendMessageFeishuMock,
@@ -89,6 +94,13 @@ describe("Feishu Card Action Handler", () => {
beforeEach(() => {
vi.clearAllMocks();
createFeishuClientMock.mockReset().mockReturnValue({
im: {
chat: {
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "group" } }),
},
},
});
vi.mocked(handleFeishuMessage)
.mockReset()
.mockResolvedValue(undefined as never);
@@ -354,6 +366,142 @@ describe("Feishu Card Action Handler", () => {
);
});
it("resolves DM chat type from the Feishu chat API when card context omits it", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "p2p" } }),
},
},
});
const event = createCardActionEvent({
token: "tok9b",
chatId: "oc_dm_chat_123",
actionValue: { text: "/help" },
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_id: "oc_dm_chat_123",
chat_type: "p2p",
}),
}),
}),
);
expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
});
it("uses resolved DM chat type when building approval cards without stored context", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_mode: "p2p" } }),
},
},
});
const event = createCardActionEvent({
token: "tok9c",
chatId: "oc_dm_chat_234",
actionValue: createFeishuCardInteractionEnvelope({
k: "meta",
a: FEISHU_APPROVAL_REQUEST_ACTION,
m: {
command: "/new",
prompt: "Start a fresh session?",
},
c: {
u: "u123",
h: "oc_dm_chat_234",
e: Date.now() + 60_000,
},
}),
});
await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
card: expect.objectContaining({
body: expect.objectContaining({
elements: expect.arrayContaining([
expect.objectContaining({
tag: "action",
actions: expect.arrayContaining([
expect.objectContaining({
value: expect.objectContaining({
c: expect.objectContaining({
t: "p2p",
}),
}),
}),
]),
}),
]),
}),
}),
}),
);
expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
});
it("falls back to p2p when Feishu chat API returns an error", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
get: vi.fn().mockResolvedValue({ code: 99, msg: "not found" }),
},
},
});
const event = createCardActionEvent({
token: "tok9d",
chatId: "oc_unknown_chat_456",
actionValue: { text: "/help" },
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_type: "p2p",
}),
}),
}),
);
});
it("falls back to p2p when Feishu chat API throws", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
get: vi.fn().mockRejectedValue(new Error("network failure")),
},
},
});
const event = createCardActionEvent({
token: "tok9e",
chatId: "oc_broken_chat_789",
actionValue: { text: "/help" },
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_type: "p2p",
}),
}),
}),
);
});
it("drops duplicate structured callback tokens", async () => {
const event = createStructuredQuickActionEvent({
token: "tok10",

View File

@@ -2,6 +2,7 @@ import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
import { decodeFeishuCardAction, buildFeishuCardActionTextFallback } from "./card-interaction.js";
import { createFeishuClient } from "./client.js";
import {
createApprovalCard,
FEISHU_APPROVAL_CANCEL_ACTION,
@@ -104,7 +105,7 @@ function releaseFeishuCardActionToken(params: { token: string; accountId: string
function buildSyntheticMessageEvent(
event: FeishuCardActionEvent,
content: string,
chatType?: "p2p" | "group",
chatType: "p2p" | "group",
): FeishuMessageEvent {
return {
sender: {
@@ -117,7 +118,7 @@ function buildSyntheticMessageEvent(
message: {
message_id: `card-action-${event.token}`,
chat_id: event.context.chat_id || event.operator.open_id,
chat_type: chatType ?? (event.context.chat_id ? "group" : "p2p"),
chat_type: chatType,
message_type: "text",
content: JSON.stringify({ text: content }),
},
@@ -136,20 +137,124 @@ async function dispatchSyntheticCommand(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
command: string;
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
botOpenId?: string;
runtime?: RuntimeEnv;
accountId?: string;
chatType?: "p2p" | "group";
}): Promise<void> {
const resolvedChatType = await resolveCardActionChatType({
event: params.event,
account: params.account,
chatType: params.chatType,
log: params.runtime?.log ?? console.log,
});
await handleFeishuMessage({
cfg: params.cfg,
event: buildSyntheticMessageEvent(params.event, params.command, params.chatType),
event: buildSyntheticMessageEvent(params.event, params.command, resolvedChatType),
botOpenId: params.botOpenId,
runtime: params.runtime,
accountId: params.accountId,
});
}
// Feishu's im.chat.get returns two fields:
// chat_mode: conversation type — "p2p" | "group" | "topic"
// chat_type: privacy classification — "private" | "public"
// We check chat_mode first because it directly indicates conversation type.
// "private" maps to "p2p" as the safe-failure direction (restrictive DM
// policy) — a private group chat misclassified as p2p is safer than the
// reverse. "topic" and "public" are treated as group semantics.
function normalizeResolvedCardActionChatType(value: unknown): "p2p" | "group" | undefined {
if (value === "group" || value === "topic" || value === "public") {
return "group";
}
if (value === "p2p" || value === "private") {
return "p2p";
}
return undefined;
}
const resolvedChatTypeCache = new Map<string, { value: "p2p" | "group"; expiresAt: number }>();
const CHAT_TYPE_CACHE_TTL_MS = 30 * 60_000;
const CHAT_TYPE_CACHE_MAX_SIZE = 5_000;
function pruneChatTypeCache(now: number): void {
for (const [key, entry] of resolvedChatTypeCache.entries()) {
if (entry.expiresAt <= now) {
resolvedChatTypeCache.delete(key);
}
}
if (resolvedChatTypeCache.size > CHAT_TYPE_CACHE_MAX_SIZE) {
const excess = resolvedChatTypeCache.size - CHAT_TYPE_CACHE_MAX_SIZE;
const iter = resolvedChatTypeCache.keys();
for (let i = 0; i < excess; i++) {
const key = iter.next().value;
if (key !== undefined) {
resolvedChatTypeCache.delete(key);
}
}
}
}
function sanitizeLogValue(v: string): string {
return v.replace(/[\r\n]/g, " ").slice(0, 500);
}
async function resolveCardActionChatType(params: {
event: FeishuCardActionEvent;
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
chatType?: "p2p" | "group";
log: (message: string) => void;
}): Promise<"p2p" | "group"> {
const explicitChatType = normalizeResolvedCardActionChatType(params.chatType);
if (explicitChatType) {
return explicitChatType;
}
const chatId = params.event.context.chat_id?.trim();
if (!chatId) {
return "p2p";
}
const cacheKey = `${params.account.accountId}:${chatId}`;
const now = Date.now();
pruneChatTypeCache(now);
const cached = resolvedChatTypeCache.get(cacheKey);
if (cached) {
return cached.value;
}
try {
const response = (await createFeishuClient(params.account).im.chat.get({
path: { chat_id: chatId },
})) as { code?: number; msg?: string; data?: { chat_type?: unknown; chat_mode?: unknown } };
if (response.code === 0) {
const resolvedChatType =
normalizeResolvedCardActionChatType(response.data?.chat_mode) ??
normalizeResolvedCardActionChatType(response.data?.chat_type);
if (resolvedChatType) {
resolvedChatTypeCache.set(cacheKey, { value: resolvedChatType, expiresAt: now + CHAT_TYPE_CACHE_TTL_MS });
return resolvedChatType;
}
params.log(
`feishu[${params.account.accountId}]: card action missing chat type for chat; defaulting to p2p`,
);
} else {
params.log(
`feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(response.msg ?? "unknown error")}; defaulting to p2p`,
);
}
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
params.log(
`feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(message)}; defaulting to p2p`,
);
}
return "p2p";
}
async function sendInvalidInteractionNotice(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
@@ -246,7 +351,12 @@ export async function handleFeishuCardAction(params: {
prompt,
sessionKey: envelope.c?.s,
expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS,
chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"),
chatType: await resolveCardActionChatType({
event,
account,
chatType: envelope.c?.t,
log,
}),
confirmLabel: command === "/reset" ? "Reset" : "Confirm",
}),
accountId,
@@ -282,10 +392,11 @@ export async function handleFeishuCardAction(params: {
cfg,
event,
command,
account,
botOpenId: params.botOpenId,
runtime,
accountId,
chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"),
chatType: envelope.c?.t,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
@@ -311,6 +422,7 @@ export async function handleFeishuCardAction(params: {
cfg,
event,
command: content,
account,
botOpenId: params.botOpenId,
runtime,
accountId,

View File

@@ -2,11 +2,8 @@ import {
resolveProviderHttpRequestConfig,
type ProviderRequestTransportOverrides,
} from "openclaw/plugin-sdk/provider-http";
import {
applyAgentDefaultModelPrimary,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { parseGoogleOauthApiKey } from "./oauth-token-shared.js";
export { applyGoogleGeminiModelDefault, GOOGLE_GEMINI_DEFAULT_MODEL } from "./onboard.js";
import {
DEFAULT_GOOGLE_API_BASE_URL,
normalizeGoogleApiBaseUrl,
@@ -24,6 +21,8 @@ export {
shouldNormalizeGoogleGenerativeAiProviderConfig,
shouldNormalizeGoogleProviderConfig,
} from "./provider-policy.js";
export { buildGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
export { buildGoogleProvider } from "./provider-registration.js";
export function parseGeminiAuth(apiKey: string): { headers: Record<string, string> } {
const parsed = apiKey.startsWith("{") ? parseGoogleOauthApiKey(apiKey) : null;
@@ -88,27 +87,3 @@ export function resolveGoogleGenerativeAiHttpRequestConfig(params: {
transport: params.transport,
});
}
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview";
export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): {
next: OpenClawConfig;
changed: boolean;
} {
const current = cfg.agents?.defaults?.model as unknown;
const currentPrimary =
typeof current === "string"
? current.trim() || undefined
: current &&
typeof current === "object" &&
typeof (current as { primary?: unknown }).primary === "string"
? ((current as { primary: string }).primary || "").trim() || undefined
: undefined;
if (currentPrimary === GOOGLE_GEMINI_DEFAULT_MODEL) {
return { next: cfg, changed: false };
}
return {
next: applyAgentDefaultModelPrimary(cfg, GOOGLE_GEMINI_DEFAULT_MODEL),
changed: true,
};
}

View File

@@ -4,6 +4,7 @@ import type {
ProviderFetchUsageSnapshotContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth-result";
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools";
import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage";
import { formatGoogleOauthApiKey, parseGoogleUsageToken } from "./oauth-token-shared.js";
@@ -29,8 +30,8 @@ async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) {
return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID);
}
export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) {
api.registerProvider({
export function buildGoogleGeminiCliProvider(): ProviderPlugin {
return {
id: PROVIDER_ID,
label: PROVIDER_LABEL,
docsPath: "/providers/models",
@@ -128,5 +129,9 @@ export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) {
};
},
fetchUsageSnapshot: async (ctx) => await fetchGeminiCliUsage(ctx),
});
};
}
export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) {
api.registerProvider(buildGoogleGeminiCliProvider());
}

View File

@@ -0,0 +1,28 @@
import {
applyAgentDefaultModelPrimary,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview";
export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): {
next: OpenClawConfig;
changed: boolean;
} {
const current = cfg.agents?.defaults?.model as unknown;
const currentPrimary =
typeof current === "string"
? current.trim() || undefined
: current &&
typeof current === "object" &&
typeof (current as { primary?: unknown }).primary === "string"
? ((current as { primary: string }).primary || "").trim() || undefined
: undefined;
if (currentPrimary === GOOGLE_GEMINI_DEFAULT_MODEL) {
return { next: cfg, changed: false };
}
return {
next: applyAgentDefaultModelPrimary(cfg, GOOGLE_GEMINI_DEFAULT_MODEL),
changed: true,
};
}

View File

@@ -1,17 +1,17 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import {
GOOGLE_GEMINI_DEFAULT_MODEL,
applyGoogleGeminiModelDefault,
normalizeGoogleProviderConfig,
normalizeGoogleModelId,
resolveGoogleGenerativeAiTransport,
} from "./api.js";
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeGoogleModelId } from "./model-id.js";
import { GOOGLE_GEMINI_DEFAULT_MODEL, applyGoogleGeminiModelDefault } from "./onboard.js";
import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js";
import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js";
import {
normalizeGoogleProviderConfig,
resolveGoogleGenerativeAiTransport,
} from "./provider-policy.js";
export function registerGoogleProvider(api: OpenClawPluginApi) {
api.registerProvider({
export function buildGoogleProvider(): ProviderPlugin {
return {
id: "google",
label: "Google AI Studio",
docsPath: "/providers/models",
@@ -50,5 +50,9 @@ export function registerGoogleProvider(api: OpenClawPluginApi) {
}),
...GOOGLE_GEMINI_PROVIDER_HOOKS,
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
});
};
}
export function registerGoogleProvider(api: OpenClawPluginApi) {
api.registerProvider(buildGoogleProvider());
}

View File

@@ -1,28 +1 @@
import {
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
const credentialPath = "plugins.entries.google.config.webSearch.apiKey";
return {
id: "gemini",
label: "Gemini (Google Search)",
hint: "Requires Google Gemini API key · Google Search grounding",
onboardingScopes: ["text-inference"],
credentialLabel: "Google Gemini API key",
envVars: ["GEMINI_API_KEY"],
placeholder: "AIza...",
signupUrl: "https://aistudio.google.com/apikey",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 20,
credentialPath,
...createWebSearchProviderContractFields({
credentialPath,
searchCredential: { type: "scoped", scopeId: "gemini" },
configuredCredential: { pluginId: "google" },
}),
createTool: () => null,
};
}
export { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js";

View File

@@ -1,12 +1,10 @@
export {
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
} from "./src/media-contract.js";
export {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "./src/media-contract.js";
} from "./media-contract-api.js";
export {
__testing as imessageConversationBindingTesting,
createIMessageConversationBindingManager,

View File

@@ -0,0 +1,7 @@
export {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots,
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
} from "./src/media-contract.js";

View File

@@ -10,6 +10,7 @@ export {
OPENAI_DEFAULT_TTS_VOICE,
} from "./default-models.js";
export { buildOpenAICodexProvider } from "./openai-codex-catalog.js";
export { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js";
export { buildOpenAIProvider } from "./openai-provider.js";
export { buildOpenAIRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
export { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js";

View File

@@ -96,6 +96,49 @@ describe("readOpenAICodexCliOAuthProfile", () => {
expect(parsed).toBeNull();
});
it("allows the runtime-only Codex CLI profile when the stored default already matches", () => {
const accessToken = buildJwt({
exp: Math.floor(Date.now() / 1000) + 600,
"https://api.openai.com/profile": {
email: "codex@example.com",
},
});
vi.spyOn(fs, "readFileSync").mockReturnValue(
JSON.stringify({
auth_mode: "chatgpt",
tokens: {
access_token: accessToken,
refresh_token: "refresh-token",
account_id: "acct_123",
},
}),
);
const firstParse = readOpenAICodexCliOAuthProfile({
store: { version: 1, profiles: {} },
});
expect(firstParse).not.toBeNull();
const parsed = readOpenAICodexCliOAuthProfile({
store: {
version: 1,
profiles: {
[OPENAI_CODEX_DEFAULT_PROFILE_ID]: firstParse!.credential,
},
},
});
expect(parsed).toMatchObject({
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
credential: {
access: accessToken,
refresh: "refresh-token",
accountId: "acct_123",
email: "codex@example.com",
},
});
});
it("returns null without logging when the Codex CLI auth file is missing", () => {
const error = Object.assign(new Error("missing"), {
code: "ENOENT",

View File

@@ -67,7 +67,6 @@ function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean
a.provider === b.provider &&
a.access === b.access &&
a.refresh === b.refresh &&
a.expires === b.expires &&
a.clientId === b.clientId &&
a.email === b.email &&
a.displayName === b.displayName &&

View File

@@ -72,6 +72,7 @@ import {
runQaDockerScaffoldCommand,
runQaDockerUpCommand,
runQaCharacterEvalCommand,
runQaCoverageReportCommand,
runQaManualLaneCommand,
runQaParityReportCommand,
runQaSuiteCommand,
@@ -336,6 +337,13 @@ describe("qa cli runtime", () => {
}
});
it("prints a markdown coverage report from scenario metadata", async () => {
await runQaCoverageReportCommand({ repoRoot: process.cwd() });
expect(stdoutWrite).toHaveBeenCalledWith(expect.stringContaining("# QA Coverage Inventory"));
expect(stdoutWrite).toHaveBeenCalledWith(expect.stringContaining("memory.recall"));
});
it("resolves character eval paths and passes model refs through", async () => {
await runQaCharacterEvalCommand({
repoRoot: "/tmp/openclaw-repo",

View File

@@ -9,6 +9,7 @@ import {
import { resolveQaParityPackScenarioIds } from "./agentic-parity.js";
import { runQaCharacterEval, type QaCharacterModelOptions } from "./character-eval.js";
import { resolveRepoRelativeOutputDir } from "./cli-paths.js";
import { buildQaCoverageInventory, renderQaCoverageMarkdownReport } from "./coverage-report.js";
import { buildQaDockerHarnessImage, writeQaDockerHarnessFiles } from "./docker-harness.js";
import { runQaDockerUp } from "./docker-up.runtime.js";
import type { QaCliBackendAuthMode } from "./gateway-child.js";
@@ -36,6 +37,7 @@ import {
type QaProviderMode,
type QaProviderModeInput,
} from "./run-config.js";
import { readQaScenarioPack } from "./scenario-catalog.js";
import { runQaSuiteFromRuntime } from "./suite-launch.runtime.js";
type InterruptibleServer = {
@@ -442,6 +444,29 @@ export async function runQaParityReportCommand(opts: {
process.exitCode = 1;
}
}
export async function runQaCoverageReportCommand(opts: {
repoRoot?: string;
output?: string;
json?: boolean;
}) {
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
const inventory = buildQaCoverageInventory(readQaScenarioPack().scenarios);
const outputPath = opts.output ? path.resolve(repoRoot, opts.output) : undefined;
const body = opts.json
? `${JSON.stringify(inventory, null, 2)}\n`
: renderQaCoverageMarkdownReport(inventory);
if (outputPath) {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, body, "utf8");
process.stdout.write(`QA coverage report: ${outputPath}\n`);
return;
}
process.stdout.write(body);
}
export async function runQaCharacterEvalCommand(opts: {
repoRoot?: string;
outputDir?: string;

View File

@@ -44,12 +44,14 @@ const {
runQaCredentialsAddCommand,
runQaCredentialsListCommand,
runQaCredentialsRemoveCommand,
runQaCoverageReportCommand,
runQaProviderServerCommand,
runQaTelegramCommand,
} = vi.hoisted(() => ({
runQaCredentialsAddCommand: vi.fn(),
runQaCredentialsListCommand: vi.fn(),
runQaCredentialsRemoveCommand: vi.fn(),
runQaCoverageReportCommand: vi.fn(),
runQaProviderServerCommand: vi.fn(),
runQaTelegramCommand: vi.fn(),
}));
@@ -72,6 +74,7 @@ vi.mock("./cli.runtime.js", () => ({
runQaCredentialsAddCommand,
runQaCredentialsListCommand,
runQaCredentialsRemoveCommand,
runQaCoverageReportCommand,
runQaProviderServerCommand,
}));
@@ -85,6 +88,7 @@ describe("qa cli registration", () => {
runQaCredentialsAddCommand.mockReset();
runQaCredentialsListCommand.mockReset();
runQaCredentialsRemoveCommand.mockReset();
runQaCoverageReportCommand.mockReset();
runQaProviderServerCommand.mockReset();
runQaTelegramCommand.mockReset();
listQaRunnerCliContributions
@@ -101,10 +105,30 @@ describe("qa cli registration", () => {
const qa = program.commands.find((command) => command.name() === "qa");
expect(qa).toBeDefined();
expect(qa?.commands.map((command) => command.name())).toEqual(
expect.arrayContaining([TEST_QA_RUNNER.commandName, "telegram", "credentials"]),
expect.arrayContaining([TEST_QA_RUNNER.commandName, "telegram", "credentials", "coverage"]),
);
});
it("routes coverage report flags into the qa runtime command", async () => {
await program.parseAsync([
"node",
"openclaw",
"qa",
"coverage",
"--repo-root",
"/tmp/openclaw-repo",
"--output",
".artifacts/qa-coverage.md",
"--json",
]);
expect(runQaCoverageReportCommand).toHaveBeenCalledWith({
repoRoot: "/tmp/openclaw-repo",
output: ".artifacts/qa-coverage.md",
json: true,
});
});
it("delegates discovered qa runner registration through the generic host seam", () => {
const [{ registration }] = listQaRunnerCliContributions.mock.results[0]?.value;
expect(registration.register).toHaveBeenCalledTimes(1);

View File

@@ -60,6 +60,12 @@ async function runQaParityReport(opts: {
const runtime = await loadQaLabCliRuntime();
await runtime.runQaParityReportCommand(opts);
}
async function runQaCoverageReport(opts: { repoRoot?: string; output?: string; json?: boolean }) {
const runtime = await loadQaLabCliRuntime();
await runtime.runQaCoverageReportCommand(opts);
}
async function runQaCharacterEval(opts: {
repoRoot?: string;
outputDir?: string;
@@ -302,6 +308,15 @@ export function registerQaLabCli(program: Command) {
},
);
qa.command("coverage")
.description("Print the markdown scenario coverage inventory")
.option("--repo-root <path>", "Repository root to target when writing --output")
.option("--output <path>", "Write the coverage inventory to this path")
.option("--json", "Print JSON instead of Markdown", false)
.action(async (opts: { repoRoot?: string; output?: string; json?: boolean }) => {
await runQaCoverageReport(opts);
});
qa.command("character-eval")
.description("Run the character QA scenario across live models and write a judged report")
.option("--repo-root <path>", "Repository root to target when running from a neutral cwd")

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { buildQaCoverageInventory, renderQaCoverageMarkdownReport } from "./coverage-report.js";
import { readQaScenarioPack } from "./scenario-catalog.js";
describe("qa coverage report", () => {
it("groups scenario coverage metadata by theme and surface", () => {
const inventory = buildQaCoverageInventory(readQaScenarioPack().scenarios);
expect(inventory.scenarioCount).toBeGreaterThan(0);
expect(inventory.coverageIdCount).toBeGreaterThan(0);
expect(inventory.primaryCoverageIdCount).toBeGreaterThan(0);
expect(inventory.secondaryCoverageIdCount).toBeGreaterThan(0);
expect(inventory.overlappingCoverage.length).toBeGreaterThan(0);
expect(inventory.missingCoverage).toEqual([]);
expect(inventory.byTheme.memory.some((feature) => feature.id === "memory.recall")).toBe(true);
expect(inventory.bySurface.memory.some((feature) => feature.id === "memory.recall")).toBe(true);
});
it("renders a compact markdown inventory", () => {
const report = renderQaCoverageMarkdownReport(
buildQaCoverageInventory(readQaScenarioPack().scenarios),
);
expect(report).toContain("# QA Coverage Inventory");
expect(report).toContain("- Missing coverage metadata: 0");
expect(report).toContain("- Overlapping coverage IDs:");
expect(report).toContain("memory.recall");
expect(report).toContain("primary: memory-recall (qa/scenarios/memory/memory-recall.md)");
expect(report).toContain("secondary: active-memory-preprompt-recall");
});
});

View File

@@ -0,0 +1,192 @@
import type { QaSeedScenarioWithSource } from "./scenario-catalog.js";
export type QaCoverageScenarioSummary = {
id: string;
title: string;
sourcePath: string;
theme: string;
surfaces: string[];
risk: string;
};
export type QaCoverageIntent = "primary" | "secondary";
export type QaCoverageScenarioReference = QaCoverageScenarioSummary & {
intent: QaCoverageIntent;
};
export type QaCoverageFeatureSummary = {
id: string;
scenarios: QaCoverageScenarioReference[];
};
export type QaCoverageInventory = {
scenarioCount: number;
coverageIdCount: number;
primaryCoverageIdCount: number;
secondaryCoverageIdCount: number;
features: QaCoverageFeatureSummary[];
overlappingCoverage: QaCoverageFeatureSummary[];
missingCoverage: QaCoverageScenarioSummary[];
byTheme: Record<string, QaCoverageFeatureSummary[]>;
bySurface: Record<string, QaCoverageFeatureSummary[]>;
};
function scenarioTheme(sourcePath: string) {
const parts = sourcePath.split("/");
return parts[2] ?? "unknown";
}
function scenarioSurfaces(scenario: QaSeedScenarioWithSource) {
return scenario.surfaces && scenario.surfaces.length > 0 ? scenario.surfaces : [scenario.surface];
}
function scenarioRisk(scenario: QaSeedScenarioWithSource) {
return scenario.risk ?? scenario.riskLevel ?? "unassigned";
}
function summarizeScenario(scenario: QaSeedScenarioWithSource): QaCoverageScenarioSummary {
return {
id: scenario.id,
title: scenario.title,
sourcePath: scenario.sourcePath,
theme: scenarioTheme(scenario.sourcePath),
surfaces: scenarioSurfaces(scenario),
risk: scenarioRisk(scenario),
};
}
function sortFeatures(features: readonly QaCoverageFeatureSummary[]) {
return features.toSorted((left, right) => left.id.localeCompare(right.id));
}
export function buildQaCoverageInventory(
scenarios: readonly QaSeedScenarioWithSource[],
): QaCoverageInventory {
const byCoverageId = new Map<string, QaCoverageFeatureSummary>();
const primaryCoverageIds = new Set<string>();
const secondaryCoverageIds = new Set<string>();
const missingCoverage: QaCoverageScenarioSummary[] = [];
const addCoverage = (
scenario: QaSeedScenarioWithSource,
coverageIds: readonly string[] | undefined,
intent: QaCoverageIntent,
) => {
const summary = summarizeScenario(scenario);
for (const coverageId of coverageIds ?? []) {
const feature = byCoverageId.get(coverageId) ?? {
id: coverageId,
scenarios: [],
};
feature.scenarios.push({ ...summary, intent });
byCoverageId.set(coverageId, feature);
if (intent === "primary") {
primaryCoverageIds.add(coverageId);
} else {
secondaryCoverageIds.add(coverageId);
}
}
};
for (const scenario of scenarios) {
if (!scenario.coverage) {
missingCoverage.push(summarizeScenario(scenario));
continue;
}
addCoverage(scenario, scenario.coverage.primary, "primary");
addCoverage(scenario, scenario.coverage.secondary, "secondary");
}
const features = sortFeatures([...byCoverageId.values()]);
const overlappingCoverage = features.filter((feature) => feature.scenarios.length > 1);
const byTheme: Record<string, QaCoverageFeatureSummary[]> = {};
const bySurface: Record<string, QaCoverageFeatureSummary[]> = {};
for (const feature of features) {
const themes = new Set(feature.scenarios.map((scenario) => scenario.theme));
for (const theme of themes) {
byTheme[theme] ??= [];
byTheme[theme].push({
...feature,
scenarios: feature.scenarios.filter((scenario) => scenario.theme === theme),
});
}
const surfaces = new Set(feature.scenarios.flatMap((scenario) => scenario.surfaces));
for (const surface of surfaces) {
bySurface[surface] ??= [];
bySurface[surface].push({
...feature,
scenarios: feature.scenarios.filter((scenario) => scenario.surfaces.includes(surface)),
});
}
}
return {
scenarioCount: scenarios.length,
coverageIdCount: features.length,
primaryCoverageIdCount: primaryCoverageIds.size,
secondaryCoverageIdCount: secondaryCoverageIds.size,
features,
overlappingCoverage,
missingCoverage,
byTheme,
bySurface,
};
}
function pushFeatureLines(lines: string[], features: readonly QaCoverageFeatureSummary[]) {
for (const feature of sortFeatures(features)) {
const scenarios = feature.scenarios
.map((scenario) => `${scenario.intent}: ${scenario.id} (${scenario.sourcePath})`)
.join(", ");
lines.push(`- ${feature.id}: ${scenarios}`);
}
}
export function renderQaCoverageMarkdownReport(inventory: QaCoverageInventory): string {
const lines: string[] = [
"# QA Coverage Inventory",
"",
`- Scenarios: ${inventory.scenarioCount}`,
`- Coverage IDs: ${inventory.coverageIdCount}`,
`- Primary coverage IDs: ${inventory.primaryCoverageIdCount}`,
`- Secondary coverage IDs: ${inventory.secondaryCoverageIdCount}`,
`- Overlapping coverage IDs: ${inventory.overlappingCoverage.length}`,
`- Missing coverage metadata: ${inventory.missingCoverage.length}`,
"",
"## By Theme",
"",
];
for (const theme of Object.keys(inventory.byTheme).toSorted()) {
lines.push(`### ${theme}`, "");
pushFeatureLines(lines, inventory.byTheme[theme] ?? []);
lines.push("");
}
lines.push("## By Surface", "");
for (const surface of Object.keys(inventory.bySurface).toSorted()) {
lines.push(`### ${surface}`, "");
pushFeatureLines(lines, inventory.bySurface[surface] ?? []);
lines.push("");
}
if (inventory.overlappingCoverage.length > 0) {
lines.push("## Overlap", "");
pushFeatureLines(lines, inventory.overlappingCoverage);
lines.push("");
}
if (inventory.missingCoverage.length > 0) {
lines.push("## Missing Metadata", "");
for (const scenario of inventory.missingCoverage.toSorted((left, right) =>
left.id.localeCompare(right.id),
)) {
lines.push(`- ${scenario.id}: ${scenario.sourcePath}`);
}
lines.push("");
}
return `${lines.join("\n").trimEnd()}\n`;
}

View File

@@ -268,38 +268,41 @@ describe("buildQaRuntimeEnv", () => {
expect(env.CODEX_HOME).toBe("/custom/codex-home");
});
it("scrubs direct and live provider keys in mock mode", () => {
const env = buildQaRuntimeEnv({
...createParams({
ANTHROPIC_API_KEY: "anthropic-live",
ANTHROPIC_OAUTH_TOKEN: "anthropic-oauth",
GEMINI_API_KEY: "gemini-live",
GEMINI_API_KEYS: "gemini-a gemini-b",
GOOGLE_API_KEY: "google-live",
OPENAI_API_KEY: "openai-live",
OPENAI_API_KEYS: "openai-a,openai-b",
CODEX_HOME: "/host/.codex",
OPENCLAW_LIVE_ANTHROPIC_KEY: "anthropic-live",
OPENCLAW_LIVE_ANTHROPIC_KEYS: "anthropic-a,anthropic-b",
OPENCLAW_LIVE_GEMINI_KEY: "gemini-live",
OPENCLAW_LIVE_OPENAI_KEY: "openai-live",
}),
providerMode: "mock-openai",
});
it.each(["mock-openai", "aimock"] as const)(
"scrubs direct and live provider keys in %s mode",
(providerMode) => {
const env = buildQaRuntimeEnv({
...createParams({
ANTHROPIC_API_KEY: "anthropic-live",
ANTHROPIC_OAUTH_TOKEN: "anthropic-oauth",
GEMINI_API_KEY: "gemini-live",
GEMINI_API_KEYS: "gemini-a gemini-b",
GOOGLE_API_KEY: "google-live",
OPENAI_API_KEY: "openai-live",
OPENAI_API_KEYS: "openai-a,openai-b",
CODEX_HOME: "/host/.codex",
OPENCLAW_LIVE_ANTHROPIC_KEY: "anthropic-live",
OPENCLAW_LIVE_ANTHROPIC_KEYS: "anthropic-a,anthropic-b",
OPENCLAW_LIVE_GEMINI_KEY: "gemini-live",
OPENCLAW_LIVE_OPENAI_KEY: "openai-live",
}),
providerMode,
});
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.OPENAI_API_KEYS).toBeUndefined();
expect(env.CODEX_HOME).toBeUndefined();
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.ANTHROPIC_OAUTH_TOKEN).toBeUndefined();
expect(env.GEMINI_API_KEY).toBeUndefined();
expect(env.GEMINI_API_KEYS).toBeUndefined();
expect(env.GOOGLE_API_KEY).toBeUndefined();
expect(env.OPENCLAW_LIVE_OPENAI_KEY).toBeUndefined();
expect(env.OPENCLAW_LIVE_ANTHROPIC_KEY).toBeUndefined();
expect(env.OPENCLAW_LIVE_ANTHROPIC_KEYS).toBeUndefined();
expect(env.OPENCLAW_LIVE_GEMINI_KEY).toBeUndefined();
});
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.OPENAI_API_KEYS).toBeUndefined();
expect(env.CODEX_HOME).toBeUndefined();
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.ANTHROPIC_OAUTH_TOKEN).toBeUndefined();
expect(env.GEMINI_API_KEY).toBeUndefined();
expect(env.GEMINI_API_KEYS).toBeUndefined();
expect(env.GOOGLE_API_KEY).toBeUndefined();
expect(env.OPENCLAW_LIVE_OPENAI_KEY).toBeUndefined();
expect(env.OPENCLAW_LIVE_ANTHROPIC_KEY).toBeUndefined();
expect(env.OPENCLAW_LIVE_ANTHROPIC_KEYS).toBeUndefined();
expect(env.OPENCLAW_LIVE_GEMINI_KEY).toBeUndefined();
},
);
it("treats restart socket closures as retryable gateway call errors", () => {
expect(__testing.isRetryableGatewayCallError("gateway closed (1006 abnormal closure)")).toBe(

View File

@@ -79,4 +79,32 @@ describe("qa aimock server", () => {
await server.stop();
}
});
it("treats OpenAI Codex model refs as OpenAI-compatible snapshots", async () => {
const server = await startQaAimockServer({
host: "127.0.0.1",
port: 0,
});
try {
const response = await fetch(`${server.baseUrl}/v1/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "openai-codex/gpt-5.4",
stream: false,
input: [makeResponsesInput("hello codex-compatible aimock")],
}),
});
expect(response.status).toBe(200);
const debug = await fetch(`${server.baseUrl}/debug/last-request`);
expect(debug.status).toBe(200);
expect(await debug.json()).toMatchObject({
model: "openai-codex/gpt-5.4",
providerVariant: "openai",
});
} finally {
await server.stop();
}
});
});

View File

@@ -27,6 +27,8 @@ describe("qa scenario catalog", () => {
expect(pack.scenarios.some((scenario) => scenario.id === "character-vibes-c3po")).toBe(true);
expect(pack.scenarios.every((scenario) => scenario.execution?.kind === "flow")).toBe(true);
expect(pack.scenarios.some((scenario) => scenario.execution.flow?.steps.length)).toBe(true);
expect(pack.scenarios.every((scenario) => scenario.coverage?.primary.length)).toBe(true);
expect(readQaScenarioById("memory-recall").coverage?.primary).toContain("memory.recall");
});
it("exposes bootstrap data from the markdown pack", () => {

View File

@@ -51,6 +51,44 @@ const qaScenarioExecutionSchema = z.object({
config: qaScenarioConfigSchema.optional(),
});
const qaCoverageIdSchema = z
.string()
.trim()
.regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/, {
message: "coverage ids must use lowercase dotted or dashed tokens",
});
const qaCoverageIdListSchema = z.array(qaCoverageIdSchema).min(1);
const qaScenarioCoverageSchema = z
.object({
primary: qaCoverageIdListSchema,
secondary: qaCoverageIdListSchema.optional(),
})
.superRefine((coverage, ctx) => {
const seen = new Set<string>();
const coverageEntries = [
["primary", coverage.primary],
["secondary", coverage.secondary],
] as const;
for (const [intent, ids] of coverageEntries) {
if (!ids) {
continue;
}
for (const [index, id] of ids.entries()) {
if (!seen.has(id)) {
seen.add(id);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [intent, index],
message: `duplicate coverage id: ${id}`,
});
}
}
});
const qaScenarioGatewayRuntimeSchema = z.object({
forwardHostHome: z.boolean().optional(),
});
@@ -138,6 +176,9 @@ const qaSeedScenarioSchema = z.object({
title: z.string().trim().min(1),
surface: z.string().trim().min(1),
category: z.string().trim().min(1).optional(),
coverage: qaScenarioCoverageSchema.optional(),
surfaces: z.array(z.string().trim().min(1)).min(1).optional(),
risk: z.enum(["low", "medium", "high"]).optional(),
capabilities: z.array(z.string().trim().min(1)).optional(),
lane: z.record(z.string(), z.union([z.boolean(), z.string()])).optional(),
riskLevel: z.string().trim().min(1).optional(),

View File

@@ -1,5 +1,27 @@
import { describe, expect, it } from "vitest";
import { setSlackChannelAllowlist } from "./shared.js";
import { createSlackPluginBase, setSlackChannelAllowlist } from "./shared.js";
describe("createSlackPluginBase", () => {
it("owns Slack native command name overrides", () => {
const plugin = createSlackPluginBase({
setup: {} as never,
setupWizard: {} as never,
});
expect(
plugin.commands?.resolveNativeCommandName?.({
commandKey: "status",
defaultName: "status",
}),
).toBe("agentstatus");
expect(
plugin.commands?.resolveNativeCommandName?.({
commandKey: "tts",
defaultName: "tts",
}),
).toBe("tts");
});
});
describe("setSlackChannelAllowlist", () => {
it("writes canonical enabled entries for setup-generated channel allowlists", () => {

View File

@@ -0,0 +1 @@
export { detectTelegramLegacyStateMigrations } from "./src/state-migrations.js";

View File

@@ -17,6 +17,9 @@
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"legacyStateMigrations": true
},
"channel": {
"id": "telegram",
"label": "Telegram",

View File

@@ -9,6 +9,10 @@ export default defineBundledChannelSetupEntry({
specifier: "./setup-plugin-api.js",
exportName: "telegramSetupPlugin",
},
legacyStateMigrations: {
specifier: "./legacy-state-migrations-api.js",
exportName: "detectTelegramLegacyStateMigrations",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",

View File

@@ -0,0 +1,151 @@
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
const DEFAULT_AGENT_ID = "main";
function normalizeAgentId(value: string | undefined | null): string {
const normalized = (value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+/g, "")
.replace(/-+$/g, "");
return normalized || DEFAULT_AGENT_ID;
}
function normalizeChannelId(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function resolveDefaultAgentId(cfg: OpenClawConfig): string {
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
const chosen = (agents.find((agent) => agent?.default) ?? agents[0])?.id;
return normalizeAgentId(chosen);
}
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const ids = new Set<string>();
for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) {
if (key) {
ids.add(normalizeAccountId(key));
}
}
return [...ids];
}
function resolveBindingAccount(params: {
binding: unknown;
channelId: string;
}): { agentId: string; accountId: string } | null {
if (!params.binding || typeof params.binding !== "object") {
return null;
}
const binding = params.binding as {
agentId?: unknown;
match?: { channel?: unknown; accountId?: unknown };
};
if (normalizeChannelId(binding.match?.channel) !== params.channelId) {
return null;
}
const accountId = typeof binding.match?.accountId === "string" ? binding.match.accountId : "";
if (!accountId.trim() || accountId.trim() === "*") {
return null;
}
return {
agentId: normalizeAgentId(typeof binding.agentId === "string" ? binding.agentId : undefined),
accountId: normalizeAccountId(accountId),
};
}
function listBoundAccountIds(cfg: OpenClawConfig, channelId: string): string[] {
const ids = new Set<string>();
for (const binding of cfg.bindings ?? []) {
const resolved = resolveBindingAccount({ binding, channelId });
if (resolved) {
ids.add(resolved.accountId);
}
}
return [...ids].toSorted((left, right) => left.localeCompare(right));
}
function resolveDefaultAgentBoundAccountId(cfg: OpenClawConfig, channelId: string): string | null {
const defaultAgentId = resolveDefaultAgentId(cfg);
for (const binding of cfg.bindings ?? []) {
const resolved = resolveBindingAccount({ binding, channelId });
if (resolved?.agentId === defaultAgentId) {
return resolved.accountId;
}
}
return null;
}
function combineAccountIds(params: {
configuredAccountIds: readonly string[];
additionalAccountIds: readonly string[];
}): string[] {
const ids = new Set<string>();
for (const id of [...params.configuredAccountIds, ...params.additionalAccountIds]) {
ids.add(normalizeAccountId(id));
}
if (ids.size === 0) {
return [DEFAULT_ACCOUNT_ID];
}
return [...ids].toSorted((left, right) => left.localeCompare(right));
}
function resolveListedDefaultAccountId(params: {
accountIds: readonly string[];
configuredDefaultAccountId: string | null | undefined;
}): string {
const configured = normalizeOptionalAccountId(params.configuredDefaultAccountId);
if (configured && params.accountIds.includes(configured)) {
return configured;
}
if (params.accountIds.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return params.accountIds[0] ?? DEFAULT_ACCOUNT_ID;
}
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
return combineAccountIds({
configuredAccountIds: listConfiguredAccountIds(cfg),
additionalAccountIds: listBoundAccountIds(cfg, "telegram"),
});
}
export function resolveDefaultTelegramAccountSelection(cfg: OpenClawConfig): {
accountId: string;
accountIds: string[];
shouldWarnMissingDefault: boolean;
} {
const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram");
if (boundDefault) {
return {
accountId: boundDefault,
accountIds: listTelegramAccountIds(cfg),
shouldWarnMissingDefault: false,
};
}
const accountIds = listTelegramAccountIds(cfg);
const resolved = resolveListedDefaultAccountId({
accountIds,
configuredDefaultAccountId: cfg.channels?.telegram?.defaultAccount,
});
return {
accountId: resolved,
accountIds,
shouldWarnMissingDefault:
resolved === accountIds[0] &&
!accountIds.includes(DEFAULT_ACCOUNT_ID) &&
accountIds.length > 1,
};
}
export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
return resolveDefaultTelegramAccountSelection(cfg).accountId;
}

View File

@@ -1,12 +1,9 @@
import util from "node:util";
import {
createAccountActionGate,
DEFAULT_ACCOUNT_ID,
listCombinedAccountIds,
normalizeAccountId,
normalizeOptionalAccountId,
resolveAccountEntry,
resolveListedDefaultAccountId,
resolveAccountWithDefaultFallback,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-core";
@@ -14,13 +11,13 @@ import type {
TelegramAccountConfig,
TelegramActionConfig,
} from "openclaw/plugin-sdk/config-runtime";
import {
listBoundAccountIds,
resolveDefaultAgentBoundAccountId,
} from "openclaw/plugin-sdk/routing";
import { formatSetExplicitDefaultInstruction } from "openclaw/plugin-sdk/routing";
import { createSubsystemLogger, isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
listTelegramAccountIds as listSelectedTelegramAccountIds,
resolveDefaultTelegramAccountSelection,
} from "./account-selection.js";
import type { TelegramTransport } from "./fetch.js";
import { resolveTelegramToken } from "./token.js";
@@ -67,22 +64,8 @@ export type TelegramMediaRuntimeOptions = {
dangerouslyAllowPrivateNetwork?: boolean;
};
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const ids = new Set<string>();
for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) {
if (key) {
ids.add(normalizeAccountId(key));
}
}
return [...ids];
}
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
const ids = listCombinedAccountIds({
configuredAccountIds: listConfiguredAccountIds(cfg),
additionalAccountIds: listBoundAccountIds(cfg, "telegram"),
fallbackAccountIdWhenEmpty: DEFAULT_ACCOUNT_ID,
});
const ids = listSelectedTelegramAccountIds(cfg);
debugAccounts("listTelegramAccountIds", ids);
return ids;
}
@@ -95,26 +78,15 @@ export function resetMissingDefaultWarnFlag(): void {
}
export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram");
if (boundDefault) {
return boundDefault;
}
const ids = listTelegramAccountIds(cfg);
const resolved = resolveListedDefaultAccountId({
accountIds: ids,
configuredDefaultAccountId: normalizeOptionalAccountId(cfg.channels?.telegram?.defaultAccount),
});
if (resolved !== ids[0] || ids.includes(DEFAULT_ACCOUNT_ID) || ids.length <= 1) {
return resolved;
}
if (ids.length > 1 && !emittedMissingDefaultWarn) {
const selection = resolveDefaultTelegramAccountSelection(cfg);
if (selection.shouldWarnMissingDefault && !emittedMissingDefaultWarn) {
emittedMissingDefaultWarn = true;
getLog().warn(
`channels.telegram: accounts.default is missing; falling back to "${ids[0]}". ` +
`channels.telegram: accounts.default is missing; falling back to "${selection.accountId}". ` +
`${formatSetExplicitDefaultInstruction("telegram")} to avoid routing surprises in multi-account setups.`,
);
}
return resolved;
return selection.accountId;
}
export function resolveTelegramAccountConfig(

View File

@@ -119,6 +119,10 @@ function installPerKeySequentializer(): void {
});
}
function mockTelegramConfigWrites() {
return vi.spyOn(configRuntime, "writeConfigFile").mockResolvedValue(undefined);
}
describe("createTelegramBot", () => {
beforeAll(() => {
process.env.TZ = "UTC";
@@ -1465,6 +1469,7 @@ describe("createTelegramBot", () => {
});
it("retries group migration updates after a bubbled handler failure", async () => {
const writeConfigFileSpy = mockTelegramConfigWrites();
loadConfig.mockReturnValue({
channels: {
telegram: {
@@ -1514,12 +1519,17 @@ describe("createTelegramBot", () => {
loadConfig.mockImplementationOnce(() => {
throw new Error("cfg boom");
});
await expect(runMiddlewareChain(ctx)).rejects.toThrow("cfg boom");
const loadConfigCallsAfterFailure = loadConfig.mock.calls.length;
await runMiddlewareChain(ctx);
try {
await expect(runMiddlewareChain(ctx)).rejects.toThrow("cfg boom");
const loadConfigCallsAfterFailure = loadConfig.mock.calls.length;
await runMiddlewareChain(ctx);
expect(loadConfigCallsAfterFailure).toBe(loadConfigCallsBeforeRetry + 1);
expect(loadConfig.mock.calls.length).toBeGreaterThan(loadConfigCallsAfterFailure);
expect(loadConfigCallsAfterFailure).toBe(loadConfigCallsBeforeRetry + 1);
expect(loadConfig.mock.calls.length).toBeGreaterThan(loadConfigCallsAfterFailure);
expect(writeConfigFileSpy).toHaveBeenCalledTimes(1);
} finally {
writeConfigFileSpy.mockRestore();
}
});
const groupPolicyCases: Array<{
@@ -3110,6 +3120,7 @@ describe("createTelegramBot", () => {
});
it("retries group migration updates after a bubbled handler failure", async () => {
const writeConfigFileSpy = mockTelegramConfigWrites();
loadConfig.mockReturnValue({
channels: {
telegram: {
@@ -3159,12 +3170,17 @@ describe("createTelegramBot", () => {
loadConfig.mockImplementationOnce(() => {
throw new Error("cfg boom");
});
await expect(runMiddlewareChain(ctx)).rejects.toThrow("cfg boom");
const loadConfigCallsAfterFailure = loadConfig.mock.calls.length;
await runMiddlewareChain(ctx);
try {
await expect(runMiddlewareChain(ctx)).rejects.toThrow("cfg boom");
const loadConfigCallsAfterFailure = loadConfig.mock.calls.length;
await runMiddlewareChain(ctx);
expect(loadConfigCallsAfterFailure).toBe(loadConfigCallsBeforeRetry + 1);
expect(loadConfig.mock.calls.length).toBeGreaterThan(loadConfigCallsAfterFailure);
expect(loadConfigCallsAfterFailure).toBe(loadConfigCallsBeforeRetry + 1);
expect(loadConfig.mock.calls.length).toBeGreaterThan(loadConfigCallsAfterFailure);
expect(writeConfigFileSpy).toHaveBeenCalledTimes(1);
} finally {
writeConfigFileSpy.mockRestore();
}
});
it("retries reaction updates after a bubbled enqueue failure", async () => {

View File

@@ -1,8 +1,8 @@
import fs from "node:fs";
import type { ChannelLegacyStateMigrationPlan } from "openclaw/plugin-sdk/channel-contract";
import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing";
import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing-paths";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveDefaultTelegramAccountId } from "./accounts.js";
import { resolveDefaultTelegramAccountId } from "./account-selection.js";
function fileExists(pathValue: string): boolean {
try {

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { assertBundledChannelEntries } from "../../test/helpers/bundled-channel-entry.ts";
import entry from "./index.js";
import setupEntry from "./setup-entry.js";
describe("twitch bundled entries", () => {
assertBundledChannelEntries({
entry,
expectedId: "twitch",
expectedName: "Twitch",
setupEntry,
});
it("loads the setup-only channel plugin", () => {
const plugin = setupEntry.loadSetupPlugin?.();
expect(plugin?.id).toBe("twitch");
expect(plugin?.setupWizard).toBeDefined();
});
});

View File

@@ -15,6 +15,7 @@
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"install": {
"minHostVersion": ">=2026.4.10"
},

View File

@@ -0,0 +1,9 @@
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./setup-plugin-api.js",
exportName: "twitchSetupPlugin",
},
});

View File

@@ -0,0 +1,3 @@
// Keep bundled setup entry imports narrow so setup loads do not pull the
// broader Twitch channel plugin surface.
export { twitchSetupPlugin } from "./src/setup-surface.js";

View File

@@ -54,6 +54,30 @@ describe("getAccountConfig", () => {
expect(result?.username).toBe("secondbot");
});
it("normalizes account ids without reading inherited account properties", () => {
const accounts = Object.create({
inherited: {
username: "inherited-bot",
accessToken: "oauth:inherited",
},
}) as Record<string, unknown>;
accounts.Secondary = {
username: "secondbot",
accessToken: "oauth:secondary",
};
const cfg = {
channels: {
twitch: {
accounts,
},
},
};
expect(getAccountConfig(cfg, "SECONDARY\r\n")).toMatchObject({ username: "secondbot" });
expect(getAccountConfig(cfg, "inherited")).toBeNull();
});
it("returns null for non-existent account ID", () => {
const result = getAccountConfig(mockMultiAccountConfig, "nonexistent");
@@ -120,6 +144,21 @@ describe("listAccountIds", () => {
} as Parameters<typeof listAccountIds>[0]),
).toEqual(["default", "secondary"]);
});
it("normalizes configured account ids", () => {
expect(
listAccountIds({
channels: {
twitch: {
accounts: {
Secondary: { username: "secondbot" },
"Alerts\r\n\u001b[31m": { username: "alerts" },
},
},
},
} as Parameters<typeof listAccountIds>[0]),
).toEqual(["alerts-31m", "secondary"]);
});
});
describe("resolveDefaultTwitchAccountId", () => {
@@ -163,4 +202,32 @@ describe("resolveTwitchAccountContext", () => {
expect(context.accountId).toBe("secondary");
expect(context.account?.username).toBe("second-bot");
});
it("keeps account and token lookup aligned after account id normalization", () => {
const context = resolveTwitchAccountContext(
{
channels: {
twitch: {
accounts: {
Secondary: {
username: "second-bot",
accessToken: "oauth:second-token",
clientId: "second-client",
channel: "#second",
},
},
},
},
} as Parameters<typeof resolveTwitchAccountContext>[0],
"secondary",
);
expect(context.accountId).toBe("secondary");
expect(context.account?.username).toBe("second-bot");
expect(context.tokenResolution).toEqual({
token: "oauth:second-token",
source: "config",
});
expect(context.configured).toBe(true);
});
});

View File

@@ -1,4 +1,8 @@
import { listCombinedAccountIds } from "openclaw/plugin-sdk/account-resolution";
import {
listCombinedAccountIds,
normalizeAccountId,
resolveNormalizedAccountEntry,
} from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveTwitchToken, type TwitchTokenResolution } from "./token.js";
import type { TwitchAccountConfig } from "./types.js";
@@ -36,14 +40,19 @@ export function getAccountConfig(
}
const cfg = coreConfig as OpenClawConfig;
const normalizedAccountId = normalizeAccountId(accountId);
const twitch = cfg.channels?.twitch;
// Access accounts via unknown to handle union type (single-account vs multi-account)
const twitchRaw = twitch as Record<string, unknown> | undefined;
const accounts = twitchRaw?.accounts as Record<string, TwitchAccountConfig> | undefined;
// For default account, check base-level config first
if (accountId === DEFAULT_ACCOUNT_ID) {
const accountFromAccounts = accounts?.[DEFAULT_ACCOUNT_ID];
if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
const accountFromAccounts = resolveNormalizedAccountEntry(
accounts,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
);
// Base-level properties that can form an implicit default account
const baseLevel = {
@@ -87,11 +96,12 @@ export function getAccountConfig(
}
// For non-default accounts, only check accounts object
if (!accounts || !accounts[accountId]) {
const account = resolveNormalizedAccountEntry(accounts, normalizedAccountId, normalizeAccountId);
if (!account) {
return null;
}
return accounts[accountId] as TwitchAccountConfig | null;
return account;
}
/**
@@ -113,16 +123,19 @@ export function listAccountIds(cfg: OpenClawConfig): string[] {
typeof twitchRaw.channel === "string");
return listCombinedAccountIds({
configuredAccountIds: Object.keys(accountMap ?? {}),
configuredAccountIds: Object.keys(accountMap ?? {}).map((accountId) =>
normalizeAccountId(accountId),
),
implicitAccountId: hasBaseLevelConfig ? DEFAULT_ACCOUNT_ID : undefined,
});
}
export function resolveDefaultTwitchAccountId(cfg: OpenClawConfig): string {
const preferred =
const preferredRaw =
typeof cfg.channels?.twitch?.defaultAccount === "string"
? cfg.channels.twitch.defaultAccount.trim()
: "";
const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : "";
const ids = listAccountIds(cfg);
if (preferred && ids.includes(preferred)) {
return preferred;
@@ -137,7 +150,9 @@ export function resolveTwitchAccountContext(
cfg: OpenClawConfig,
accountId?: string | null,
): ResolvedTwitchAccountContext {
const resolvedAccountId = accountId?.trim() || resolveDefaultTwitchAccountId(cfg);
const resolvedAccountId = accountId?.trim()
? normalizeAccountId(accountId)
: resolveDefaultTwitchAccountId(cfg);
const account = getAccountConfig(cfg, resolvedAccountId);
const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
return {

View File

@@ -20,6 +20,8 @@ import {
promptRefreshTokenSetup,
promptToken,
promptUsername,
setTwitchAccount,
twitchSetupPlugin,
twitchSetupWizard,
} from "./setup-surface.js";
import type { TwitchAccountConfig } from "./types.js";
@@ -27,10 +29,13 @@ import type { TwitchAccountConfig } from "./types.js";
// Mock the helpers we're testing
const mockPromptText = vi.fn();
const mockPromptConfirm = vi.fn();
const mockPromptNote = vi.fn();
const mockPrompter: WizardPrompter = {
text: mockPromptText,
confirm: mockPromptConfirm,
note: mockPromptNote,
} as unknown as WizardPrompter;
const originalEnvToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN;
const mockAccount: TwitchAccountConfig = {
username: "testbot",
@@ -45,6 +50,11 @@ describe("setup surface helpers", () => {
});
afterEach(() => {
if (originalEnvToken === undefined) {
delete process.env.OPENCLAW_TWITCH_ACCESS_TOKEN;
} else {
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = originalEnvToken;
}
// Don't restoreAllMocks as it breaks module-level mocks
});
@@ -198,7 +208,7 @@ describe("setup surface helpers", () => {
expect(defaultAccount?.clientId).toBe("test-client-id");
});
it("writes env-token setup to the configured default account", async () => {
it("skips env-token shortcut for non-default accounts", async () => {
mockPromptConfirm.mockReset().mockResolvedValue(true as never);
mockPromptText
.mockReset()
@@ -220,12 +230,9 @@ describe("setup surface helpers", () => {
{} as Parameters<typeof configureWithEnvToken>[5],
);
const secondaryAccount = result?.cfg.channels?.twitch?.accounts?.secondary as
| { username?: string; clientId?: string }
| undefined;
expect(secondaryAccount?.username).toBe("secondary-bot");
expect(secondaryAccount?.clientId).toBe("secondary-client");
expect(result?.cfg.channels?.twitch?.accounts?.default).toBeUndefined();
expect(result).toBeNull();
expect(mockPromptConfirm).not.toHaveBeenCalled();
expect(mockPromptText).not.toHaveBeenCalled();
});
});
@@ -251,5 +258,256 @@ describe("setup surface helpers", () => {
expect(lines).toEqual(["Twitch (secondary): configured"]);
});
it("reports status for the requested account override", async () => {
const lines = twitchSetupWizard.status?.resolveStatusLines?.({
cfg: {
channels: {
twitch: {
accounts: {
default: {
username: "default-bot",
accessToken: "oauth:default",
clientId: "default-client",
channel: "#default",
},
secondary: {
username: "secondary-bot",
accessToken: "oauth:secondary",
clientId: "secondary-client",
channel: "#secondary",
},
},
},
},
},
accountId: "secondary",
configured: true,
} as never);
expect(lines).toEqual(["Twitch (secondary): configured"]);
});
it("reports env-token default account setup as configured", async () => {
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:fromenv";
const cfg = {
channels: {
twitch: {
accounts: {
default: {
username: "env-bot",
accessToken: "",
clientId: "env-client",
channel: "#env",
},
},
},
},
} as Parameters<NonNullable<typeof twitchSetupWizard.status>["resolveConfigured"]>[0]["cfg"];
expect(twitchSetupWizard.status?.resolveConfigured({ cfg })).toBe(true);
const account = twitchSetupPlugin.config.resolveAccount(cfg, "default");
expect(await twitchSetupPlugin.config.isConfigured?.(account, cfg)).toBe(true);
});
});
describe("setup wizard account routing", () => {
it("rejects reserved account ids before using them as config keys", () => {
expect(() =>
setTwitchAccount(
{} as Parameters<typeof setTwitchAccount>[0],
{
username: "reserved-bot",
accessToken: "oauth:reserved",
clientId: "reserved-client",
channel: "#reserved",
},
"__proto__",
),
).toThrow("Invalid Twitch account id");
expect(Object.prototype).not.toHaveProperty("username");
});
it("rejects reserved account ids before env-token writes", async () => {
await expect(
configureWithEnvToken(
{} as Parameters<typeof configureWithEnvToken>[0],
mockPrompter,
null,
"oauth:fromenv",
false,
{} as Parameters<typeof configureWithEnvToken>[5],
"__proto__",
),
).rejects.toThrow("Invalid Twitch account id");
expect(mockPromptConfirm).not.toHaveBeenCalled();
});
it("normalizes account ids before rendering status lines", () => {
expect(
twitchSetupWizard.status?.resolveStatusLines?.({
cfg: {},
accountId: "Alerts\r\n\u001b[31m",
configured: false,
} as never),
).toEqual(["Twitch (alerts-31m): needs username, token, and clientId"]);
});
it("reports account-scoped DM policy config keys", () => {
expect(
twitchSetupWizard.dmPolicy?.resolveConfigKeys?.(
{
channels: {
twitch: {
defaultAccount: "secondary",
},
},
} as Parameters<
NonNullable<NonNullable<typeof twitchSetupWizard.dmPolicy>["resolveConfigKeys"]>
>[0],
undefined,
),
).toEqual({
policyKey: "channels.twitch.accounts.secondary.allowedRoles",
allowFromKey: "channels.twitch.accounts.secondary.allowFrom",
});
expect(twitchSetupWizard.dmPolicy?.resolveConfigKeys?.({} as never, "alerts")).toEqual({
policyKey: "channels.twitch.accounts.alerts.allowedRoles",
allowFromKey: "channels.twitch.accounts.alerts.allowFrom",
});
});
it("writes to the requested account when defaultAccount is not created yet", async () => {
mockPromptText
.mockReset()
.mockResolvedValueOnce("secondary-bot" as never)
.mockResolvedValueOnce("oauth:secondary" as never)
.mockResolvedValueOnce("secondary-client" as never)
.mockResolvedValueOnce("#secondary" as never);
mockPromptConfirm.mockReset().mockResolvedValue(false as never);
const result = await twitchSetupWizard.finalize?.({
cfg: {
channels: {
twitch: {
defaultAccount: "secondary",
accounts: {
default: {
username: "default-bot",
accessToken: "oauth:default",
clientId: "default-client",
channel: "#default",
},
},
},
},
} as Parameters<NonNullable<typeof twitchSetupWizard.finalize>>[0]["cfg"],
accountId: "secondary",
credentialValues: {},
runtime: {} as Parameters<NonNullable<typeof twitchSetupWizard.finalize>>[0]["runtime"],
prompter: mockPrompter,
options: {},
forceAllowFrom: false,
});
const twitch = result?.cfg?.channels?.twitch;
expect(twitch?.accounts?.secondary?.username).toBe("secondary-bot");
expect(twitch?.accounts?.secondary?.accessToken).toBe("oauth:secondary");
expect(twitch?.accounts?.default?.username).toBe("default-bot");
});
it("persists a token instead of using env-token shortcut for non-default finalize", async () => {
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:fromenv";
mockPromptText
.mockReset()
.mockResolvedValueOnce("secondary-bot" as never)
.mockResolvedValueOnce("oauth:persisted" as never)
.mockResolvedValueOnce("secondary-client" as never)
.mockResolvedValueOnce("#secondary" as never);
mockPromptConfirm.mockReset().mockResolvedValue(false as never);
const result = await twitchSetupWizard.finalize?.({
cfg: {
channels: {
twitch: {
accounts: {},
},
},
} as Parameters<NonNullable<typeof twitchSetupWizard.finalize>>[0]["cfg"],
accountId: "secondary",
credentialValues: {},
runtime: {} as Parameters<NonNullable<typeof twitchSetupWizard.finalize>>[0]["runtime"],
prompter: mockPrompter,
options: {},
forceAllowFrom: false,
});
const twitch = result?.cfg?.channels?.twitch;
expect(twitch?.accounts?.secondary?.accessToken).toBe("oauth:persisted");
expect(mockPromptConfirm).toHaveBeenCalledTimes(1);
expect(mockPromptConfirm).toHaveBeenCalledWith({
message: "Enable automatic token refresh (requires client secret and refresh token)?",
initialValue: false,
});
});
});
describe("setup-only plugin config", () => {
it("lists all configured Twitch accounts", () => {
const cfg = {
channels: {
twitch: {
defaultAccount: "secondary",
accounts: {
default: {
username: "default-bot",
accessToken: "oauth:default",
clientId: "default-client",
channel: "#default",
},
secondary: {
username: "secondary-bot",
accessToken: "oauth:secondary",
clientId: "secondary-client",
channel: "#secondary",
},
},
},
},
} as Parameters<typeof twitchSetupPlugin.config.listAccountIds>[0];
expect(twitchSetupPlugin.config.listAccountIds(cfg)).toEqual(["default", "secondary"]);
expect(twitchSetupPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
});
it("normalizes exposed account ids", () => {
const cfg = {
channels: {
twitch: {
accounts: {
Secondary: {
username: "secondary-bot",
accessToken: "oauth:secondary",
clientId: "secondary-client",
channel: "#secondary",
},
},
},
},
} as Parameters<typeof twitchSetupPlugin.config.listAccountIds>[0];
expect(twitchSetupPlugin.config.listAccountIds(cfg)).toEqual(["secondary"]);
expect(twitchSetupPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
expect(twitchSetupPlugin.config.resolveAccount(cfg, "SECONDARY\r\n").accountId).toBe(
"secondary",
);
expect(twitchSetupPlugin.config.resolveAccount(cfg, "SECONDARY\r\n").username).toBe(
"secondary-bot",
);
});
});
});

View File

@@ -2,6 +2,8 @@
* Twitch setup wizard surface for CLI setup.
*/
import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
import { getChatChannelMeta, type ChannelPlugin } from "openclaw/plugin-sdk/core";
import {
formatDocsLink,
type ChannelSetupAdapter,
@@ -9,16 +11,37 @@ import {
type ChannelSetupWizard,
type OpenClawConfig,
type WizardPrompter,
normalizeAccountId,
} from "openclaw/plugin-sdk/setup";
import { DEFAULT_ACCOUNT_ID, getAccountConfig, resolveDefaultTwitchAccountId } from "./config.js";
import {
DEFAULT_ACCOUNT_ID,
getAccountConfig,
listAccountIds,
resolveDefaultTwitchAccountId,
resolveTwitchAccountContext,
} from "./config.js";
import type { TwitchAccountConfig, TwitchRole } from "./types.js";
import { isAccountConfigured } from "./utils/twitch.js";
const channel = "twitch" as const;
const INVALID_ACCOUNT_ID_MESSAGE = "Invalid Twitch account id";
function normalizeRequestedSetupAccountId(accountId: string): string {
const normalized = normalizeOptionalAccountId(accountId);
if (!normalized) {
throw new Error(INVALID_ACCOUNT_ID_MESSAGE);
}
return normalized;
}
function resolveSetupAccountId(cfg: OpenClawConfig, requestedAccountId?: string): string {
const requested = requestedAccountId?.trim();
if (requested) {
return normalizeRequestedSetupAccountId(requested);
}
function resolveSetupAccountId(cfg: OpenClawConfig): string {
const preferred = cfg.channels?.twitch?.defaultAccount?.trim();
return preferred || resolveDefaultTwitchAccountId(cfg);
return preferred ? normalizeAccountId(preferred) : resolveDefaultTwitchAccountId(cfg);
}
export function setTwitchAccount(
@@ -26,7 +49,10 @@ export function setTwitchAccount(
account: Partial<TwitchAccountConfig>,
accountId: string = resolveSetupAccountId(cfg),
): OpenClawConfig {
const existing = getAccountConfig(cfg, accountId);
const resolvedAccountId = accountId.trim()
? normalizeRequestedSetupAccountId(accountId)
: resolveSetupAccountId(cfg);
const existing = getAccountConfig(cfg, resolvedAccountId);
const merged: TwitchAccountConfig = {
username: account.username ?? existing?.username ?? "",
accessToken: account.accessToken ?? existing?.accessToken ?? "",
@@ -55,7 +81,7 @@ export function setTwitchAccount(
...((
(cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined
)?.accounts as Record<string, unknown> | undefined),
[accountId]: merged,
[resolvedAccountId]: merged,
},
},
},
@@ -192,7 +218,15 @@ export async function configureWithEnvToken(
envToken: string,
forceAllowFrom: boolean,
dmPolicy: ChannelSetupDmPolicy,
accountId: string = resolveSetupAccountId(cfg),
): Promise<{ cfg: OpenClawConfig } | null> {
const resolvedAccountId = accountId.trim()
? normalizeRequestedSetupAccountId(accountId)
: resolveSetupAccountId(cfg);
if (resolvedAccountId !== DEFAULT_ACCOUNT_ID) {
return null;
}
const useEnv = await prompter.confirm({
message: "Twitch env var OPENCLAW_TWITCH_ACCESS_TOKEN detected. Use env token?",
initialValue: true,
@@ -204,15 +238,25 @@ export async function configureWithEnvToken(
const username = await promptUsername(prompter, account);
const clientId = await promptClientId(prompter, account);
const cfgWithAccount = setTwitchAccount(cfg, {
username,
clientId,
accessToken: "",
enabled: true,
});
const cfgWithAccount = setTwitchAccount(
cfg,
{
username,
clientId,
accessToken: "",
enabled: true,
},
resolvedAccountId,
);
if (forceAllowFrom && dmPolicy.promptAllowFrom) {
return { cfg: await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) };
return {
cfg: await dmPolicy.promptAllowFrom({
cfg: cfgWithAccount,
prompter,
accountId: resolvedAccountId,
}),
};
}
return { cfg: cfgWithAccount };
@@ -222,9 +266,10 @@ function setTwitchAccessControl(
cfg: OpenClawConfig,
allowedRoles: TwitchRole[],
requireMention: boolean,
accountId?: string,
): OpenClawConfig {
const accountId = resolveSetupAccountId(cfg);
const account = getAccountConfig(cfg, accountId);
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
const account = getAccountConfig(cfg, resolvedAccountId);
if (!account) {
return cfg;
}
@@ -236,12 +281,15 @@ function setTwitchAccessControl(
allowedRoles,
requireMention,
},
accountId,
resolvedAccountId,
);
}
function resolveTwitchGroupPolicy(cfg: OpenClawConfig): "open" | "allowlist" | "disabled" {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg));
function resolveTwitchGroupPolicy(
cfg: OpenClawConfig,
accountId?: string,
): "open" | "allowlist" | "disabled" {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
if (account?.allowedRoles?.includes("all")) {
return "open";
}
@@ -254,19 +302,27 @@ function resolveTwitchGroupPolicy(cfg: OpenClawConfig): "open" | "allowlist" | "
function setTwitchGroupPolicy(
cfg: OpenClawConfig,
policy: "open" | "allowlist" | "disabled",
accountId?: string,
): OpenClawConfig {
const allowedRoles: TwitchRole[] =
policy === "open" ? ["all"] : policy === "allowlist" ? ["moderator", "vip"] : [];
return setTwitchAccessControl(cfg, allowedRoles, true);
return setTwitchAccessControl(cfg, allowedRoles, true, accountId);
}
const twitchDmPolicy: ChannelSetupDmPolicy = {
label: "Twitch",
channel,
policyKey: "channels.twitch.allowedRoles",
allowFromKey: "channels.twitch.accounts.<default>.allowFrom",
getCurrent: (cfg) => {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg));
policyKey: "channels.twitch.accounts.default.allowedRoles",
allowFromKey: "channels.twitch.accounts.default.allowFrom",
resolveConfigKeys: (cfg, accountId) => {
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
return {
policyKey: `channels.twitch.accounts.${resolvedAccountId}.allowedRoles`,
allowFromKey: `channels.twitch.accounts.${resolvedAccountId}.allowFrom`,
};
},
getCurrent: (cfg, accountId) => {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
if (account?.allowedRoles?.includes("all")) {
return "open";
}
@@ -275,14 +331,14 @@ const twitchDmPolicy: ChannelSetupDmPolicy = {
}
return "disabled";
},
setPolicy: (cfg, policy) => {
setPolicy: (cfg, policy, accountId) => {
const allowedRoles: TwitchRole[] =
policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"];
return setTwitchAccessControl(cfg, allowedRoles, true);
return setTwitchAccessControl(cfg, allowedRoles, true, accountId);
},
promptAllowFrom: async ({ cfg, prompter }) => {
const accountId = resolveSetupAccountId(cfg);
const account = getAccountConfig(cfg, accountId);
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
const account = getAccountConfig(cfg, resolvedAccountId);
const existingAllowFrom = account?.allowFrom ?? [];
const entry = await prompter.text({
@@ -302,7 +358,7 @@ const twitchDmPolicy: ChannelSetupDmPolicy = {
...(account ?? undefined),
allowFrom,
},
accountId,
resolvedAccountId,
);
},
};
@@ -311,16 +367,16 @@ const twitchGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
label: "Twitch chat",
placeholder: "",
skipAllowlistEntries: true,
currentPolicy: ({ cfg }) => resolveTwitchGroupPolicy(cfg),
currentEntries: ({ cfg }) => {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg));
currentPolicy: ({ cfg, accountId }) => resolveTwitchGroupPolicy(cfg, accountId),
currentEntries: ({ cfg, accountId }) => {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
return account?.allowFrom ?? [];
},
updatePrompt: ({ cfg }) => {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg));
updatePrompt: ({ cfg, accountId }) => {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
return Boolean(account?.allowedRoles?.length || account?.allowFrom?.length);
},
setPolicy: ({ cfg, policy }) => setTwitchGroupPolicy(cfg, policy),
setPolicy: ({ cfg, accountId, policy }) => setTwitchGroupPolicy(cfg, policy, accountId),
resolveAllowlist: async () => [],
applyAllowlist: ({ cfg }) => cfg,
};
@@ -339,29 +395,28 @@ export const twitchSetupAdapter: ChannelSetupAdapter = {
export const twitchSetupWizard: ChannelSetupWizard = {
channel,
resolveAccountIdForConfigure: ({ defaultAccountId }) => defaultAccountId,
resolveAccountIdForConfigure: ({ cfg, accountOverride }) =>
resolveSetupAccountId(cfg, accountOverride),
resolveShouldPromptAccountIds: () => false,
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs username, token, and clientId",
configuredHint: "configured",
unconfiguredHint: "needs setup",
resolveConfigured: ({ cfg }) => {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg));
return account ? isAccountConfigured(account) : false;
resolveConfigured: ({ cfg, accountId }) => {
return resolveTwitchAccountContext(cfg, resolveSetupAccountId(cfg, accountId)).configured;
},
resolveStatusLines: ({ cfg }) => {
const accountId = resolveSetupAccountId(cfg);
const account = getAccountConfig(cfg, accountId);
const configured = account ? isAccountConfigured(account) : false;
resolveStatusLines: ({ cfg, accountId }) => {
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
const configured = resolveTwitchAccountContext(cfg, resolvedAccountId).configured;
return [
`Twitch${accountId !== DEFAULT_ACCOUNT_ID ? ` (${accountId})` : ""}: ${configured ? "configured" : "needs username, token, and clientId"}`,
`Twitch${resolvedAccountId !== DEFAULT_ACCOUNT_ID ? ` (${resolvedAccountId})` : ""}: ${configured ? "configured" : "needs username, token, and clientId"}`,
];
},
},
credentials: [],
finalize: async ({ cfg, prompter, forceAllowFrom }) => {
const accountId = resolveSetupAccountId(cfg);
finalize: async ({ cfg, accountId: requestedAccountId, prompter, forceAllowFrom }) => {
const accountId = resolveSetupAccountId(cfg, requestedAccountId);
const account = getAccountConfig(cfg, accountId);
if (!account || !isAccountConfigured(account)) {
@@ -370,7 +425,7 @@ export const twitchSetupWizard: ChannelSetupWizard = {
const envToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN?.trim();
if (envToken && !account?.accessToken) {
if (accountId === DEFAULT_ACCOUNT_ID && envToken && !account?.accessToken) {
const envResult = await configureWithEnvToken(
cfg,
prompter,
@@ -378,6 +433,7 @@ export const twitchSetupWizard: ChannelSetupWizard = {
envToken,
forceAllowFrom,
twitchDmPolicy,
accountId,
);
if (envResult) {
return envResult;
@@ -406,7 +462,7 @@ export const twitchSetupWizard: ChannelSetupWizard = {
const cfgWithAllowFrom =
forceAllowFrom && twitchDmPolicy.promptAllowFrom
? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter })
? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter, accountId })
: cfgWithAccount;
return { cfg: cfgWithAllowFrom };
@@ -426,3 +482,39 @@ export const twitchSetupWizard: ChannelSetupWizard = {
};
},
};
type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null };
export const twitchSetupPlugin: ChannelPlugin<ResolvedTwitchAccount> = {
id: channel,
meta: getChatChannelMeta(channel),
capabilities: {
chatTypes: ["group"],
},
config: {
listAccountIds: (cfg) => listAccountIds(cfg),
resolveAccount: (cfg, accountId) => {
const resolvedAccountId = normalizeAccountId(accountId ?? resolveDefaultTwitchAccountId(cfg));
const account = getAccountConfig(cfg, resolvedAccountId);
if (!account) {
return {
accountId: resolvedAccountId,
username: "",
accessToken: "",
clientId: "",
channel: "",
enabled: false,
};
}
return {
accountId: resolvedAccountId,
...account,
};
},
defaultAccountId: (cfg) => resolveDefaultTwitchAccountId(cfg),
isConfigured: (account, cfg) => resolveTwitchAccountContext(cfg, account?.accountId).configured,
isEnabled: (account) => account.enabled !== false,
},
setup: twitchSetupAdapter,
setupWizard: twitchSetupWizard,
};

View File

@@ -65,6 +65,27 @@ describe("token", () => {
expect(result.source).toBe("config");
});
it("should resolve token from normalized account id", () => {
const result = resolveTwitchToken(
{
channels: {
twitch: {
accounts: {
Secondary: {
username: "secondary",
accessToken: "oauth:secondary-token",
},
},
},
},
} as unknown as OpenClawConfig,
{ accountId: "secondary" },
);
expect(result.token).toBe("oauth:secondary-token");
expect(result.source).toBe("config");
});
it("should prioritize config token over env var (simplified config)", () => {
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:env-token";

View File

@@ -9,8 +9,12 @@
* 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
*/
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
resolveNormalizedAccountEntry,
} from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
export type TwitchTokenSource = "env" | "config" | "none";
@@ -56,10 +60,8 @@ export function resolveTwitchToken(
// Get merged account config (handles both simplified and multi-account patterns)
const twitchCfg = cfg?.channels?.twitch;
const accountCfg =
accountId === DEFAULT_ACCOUNT_ID
? (twitchCfg?.accounts?.[DEFAULT_ACCOUNT_ID] as Record<string, unknown> | undefined)
: (twitchCfg?.accounts?.[accountId] as Record<string, unknown> | undefined);
const accounts = twitchCfg?.accounts as Record<string, Record<string, unknown>> | undefined;
const accountCfg = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
// For default account, also check base-level config
let token: string | undefined;

View File

@@ -0,0 +1,6 @@
import { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./src/session-contract.js";
export const whatsappLegacySessionSurface = {
isLegacyGroupSessionKey,
canonicalizeLegacySessionKey,
};

View File

@@ -0,0 +1 @@
export { detectWhatsAppLegacyStateMigrations } from "./src/state-migrations.js";

View File

@@ -25,6 +25,10 @@
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"legacyStateMigrations": true,
"legacySessionSurfaces": true
},
"channel": {
"id": "whatsapp",
"label": "WhatsApp",

View File

@@ -10,4 +10,12 @@ export default defineBundledChannelSetupEntry({
specifier: "./setup-plugin-api.js",
exportName: "whatsappSetupPlugin",
},
legacyStateMigrations: {
specifier: "./legacy-state-migrations-api.js",
exportName: "detectWhatsAppLegacyStateMigrations",
},
legacySessionSurface: {
specifier: "./legacy-session-surface-api.js",
exportName: "whatsappLegacySessionSurface",
},
});

View File

@@ -1,4 +1,4 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
function extractLegacyWhatsAppGroupId(key: string): string | null {
const trimmed = key.trim();

View File

@@ -640,6 +640,10 @@
"types": "./dist/plugin-sdk/channel-pairing.d.ts",
"default": "./dist/plugin-sdk/channel-pairing.js"
},
"./plugin-sdk/channel-pairing-paths": {
"types": "./dist/plugin-sdk/channel-pairing-paths.d.ts",
"default": "./dist/plugin-sdk/channel-pairing-paths.js"
},
"./plugin-sdk/channel-policy": {
"types": "./dist/plugin-sdk/channel-policy.d.ts",
"default": "./dist/plugin-sdk/channel-policy.js"

View File

@@ -368,11 +368,10 @@ describe("chunkMarkdown", () => {
});
it("does not break surrogate pairs when splitting long CJK lines", () => {
// "𠀀" (U+20000) is a surrogate pair: 2 UTF-16 code units per character.
// A line of 500 such characters = 1000 UTF-16 code units.
// With tokens=99 (odd), the fine-split must not cut inside a pair.
// With an odd token budget, the fine-split must not cut inside a pair.
const surrogateChar = "\u{20000}"; // 𠀀
const longLine = surrogateChar.repeat(500);
const chunks = chunkMarkdown(longLine, { tokens: 99, overlap: 0 });
const longLine = surrogateChar.repeat(120);
const chunks = chunkMarkdown(longLine, { tokens: 31, overlap: 0 });
for (const chunk of chunks) {
// No chunk should contain the Unicode replacement character U+FFFD,
// which would indicate a broken surrogate pair.

View File

@@ -13,5 +13,6 @@ Key workflow:
- `qa suite` is the executable frontier subset / regression loop.
- `qa manual` is the scoped personality and style probe after the executable subset is green.
- `qa coverage` prints the scenario coverage inventory from scenario frontmatter.
Keep this folder in git. Add new scenarios here before wiring them into automation.

View File

@@ -4,6 +4,11 @@
id: instruction-followthrough-repo-contract
title: Instruction followthrough repo contract
surface: repo-contract
coverage:
primary:
- agents.instructions
secondary:
- runtime.first-action
objective: Verify the agent reads repo instruction files first, follows the required tool order, and completes the first feasible action instead of stopping at a plan.
successCriteria:
- Agent reads the seeded instruction files before writing the requested artifact.

View File

@@ -4,6 +4,11 @@
id: subagent-fanout-synthesis
title: Subagent fanout synthesis
surface: subagents
coverage:
primary:
- agents.subagents
secondary:
- agents.synthesis
objective: Verify the agent can delegate multiple bounded subagent tasks and fold both results back into one parent reply.
successCriteria:
- Parent flow launches at least two bounded subagent tasks.

View File

@@ -4,6 +4,9 @@
id: subagent-handoff
title: Subagent handoff
surface: subagents
coverage:
primary:
- agents.subagents
objective: Verify the agent can delegate a bounded task to a subagent and fold the result back into the main thread.
successCriteria:
- Agent launches a bounded subagent task.

View File

@@ -4,6 +4,11 @@
id: channel-chat-baseline
title: Channel baseline conversation
surface: channel
coverage:
primary:
- channels.group-messages
secondary:
- channels.qa-channel
objective: Verify the QA agent can respond correctly in a shared channel and respect mention-driven group semantics.
successCriteria:
- Agent replies in the shared channel transcript.

View File

@@ -4,6 +4,11 @@
id: dm-chat-baseline
title: DM baseline conversation
surface: dm
coverage:
primary:
- channels.dm
secondary:
- channels.qa-channel
objective: Verify the QA agent can chat coherently in a DM, explain the QA setup, and stay in character.
successCriteria:
- Agent replies in DM without channel routing mistakes.

View File

@@ -4,6 +4,11 @@
id: reaction-edit-delete
title: Reaction, edit, delete lifecycle
surface: message-actions
coverage:
primary:
- channels.message-actions
secondary:
- channels.qa-channel
objective: Verify the agent can use channel-owned message actions and that the QA transcript reflects them.
successCriteria:
- Agent adds at least one reaction.

View File

@@ -4,6 +4,11 @@
id: thread-follow-up
title: Threaded follow-up
surface: thread
coverage:
primary:
- channels.threads
secondary:
- channels.qa-channel
objective: Verify the agent can keep follow-up work inside a thread and not leak context into the root channel.
successCriteria:
- Agent creates or uses a thread for deeper work.

View File

@@ -4,6 +4,11 @@
id: character-vibes-c3po
title: "Nervous release protocol chat"
surface: character
coverage:
primary:
- character.persona
secondary:
- workspace.artifacts
objective: Capture a natural multi-turn C-3PO-flavored character conversation with real workspace help so another model can later grade naturalness, vibe, and funniness from the raw transcript.
successCriteria:
- Agent gets a natural multi-turn conversation, and any missed replies stay visible in the transcript instead of aborting capture.

View File

@@ -4,6 +4,11 @@
id: character-vibes-gollum
title: "Late-night deploy helper chat"
surface: character
coverage:
primary:
- character.persona
secondary:
- workspace.artifacts
objective: Capture a natural multi-turn character conversation with real workspace help so another model can later grade naturalness, vibe, and funniness from the raw transcript.
successCriteria:
- Agent gets a natural multi-turn conversation, and any missed replies stay visible in the transcript instead of aborting capture.

View File

@@ -4,6 +4,11 @@
id: config-apply-restart-wakeup
title: Config apply restart wake-up
surface: config
coverage:
primary:
- config.restart-apply
secondary:
- runtime.gateway-restart
objective: Verify a restart-required config.apply restarts cleanly and delivers the post-restart wake message back into the QA channel.
successCriteria:
- config.apply schedules a restart-required change.

View File

@@ -4,6 +4,11 @@
id: config-patch-hot-apply
title: Config patch skill disable
surface: config
coverage:
primary:
- config.hot-apply
secondary:
- plugins.skills
objective: Verify config.patch can disable a workspace skill and the restarted gateway exposes the new disabled state cleanly.
successCriteria:
- config.patch succeeds for the skill toggle change.

View File

@@ -4,6 +4,11 @@
id: config-restart-capability-flip
title: Config restart capability flip
surface: config
coverage:
primary:
- config.restart-apply
secondary:
- plugins.capabilities
objective: Verify a restart-triggering config change flips capability inventory and the same session successfully uses the newly restored tool after wake-up.
successCriteria:
- Capability is absent before the restart-triggering patch.

View File

@@ -5,13 +5,24 @@ Single source of truth for repo-backed QA suite bootstrap data.
- `index.md` defines pack-level bootstrap data
- each nested `*.md` scenario defines one runnable test via `qa-scenario` + `qa-flow`
- scenario markdown may also define category metadata, required plugins, lane filters,
and gateway config patching
- scenario markdown may also define coverage IDs, category metadata, required plugins,
lane filters, and gateway config patching
- kickoff mission
- QA operator identity
- scenario files under one-level theme directories
Coverage tracking:
- add `coverage.primary` IDs to each scenario's `qa-scenario` block
- add `coverage.secondary` only when a scenario intentionally protects another behavior
- keep IDs behavior-shaped, broad enough to reuse, lowercase, and dotted or dashed
- prefer reusing an existing feature ID over minting a scenario-shaped ID
- avoid copying the scenario title into coverage IDs
- use `pnpm openclaw qa coverage` to render the current inventory
- treat the old `coverage: ["id"]` / `coverage: - id` list shape as invalid
- keep source-path tracking in the report, not in the scenario schema
Theme directories:
- `agents/` - agent behavior, instructions, and subagent flows

View File

@@ -4,6 +4,11 @@
id: image-generation-roundtrip
title: Image generation roundtrip
surface: image-generation
coverage:
primary:
- media.image-generation
secondary:
- channels.qa-channel
objective: Verify a generated image is saved as media, reattached on the next turn, and described correctly through the vision path.
successCriteria:
- image_generate produces a saved MEDIA artifact.

View File

@@ -4,6 +4,11 @@
id: image-understanding-attachment
title: Image understanding from attachment
surface: image-understanding
coverage:
primary:
- media.image-understanding
secondary:
- channels.qa-channel
objective: Verify an attached image reaches the agent model and the agent can describe what it sees.
successCriteria:
- Agent receives at least one image attachment.

View File

@@ -4,6 +4,11 @@
id: native-image-generation
title: Native image generation
surface: image-generation
coverage:
primary:
- media.image-generation
secondary:
- tools.native-image-generation
objective: Verify image_generate appears when configured and returns a real saved media artifact.
successCriteria:
- image_generate appears in the effective tool inventory.

View File

@@ -4,6 +4,11 @@
id: active-memory-preprompt-recall
title: Active Memory pre-reply recall
surface: memory
coverage:
primary:
- memory.active-recall
secondary:
- memory.recall
objective: Verify Active Memory surfaces a memory-only preference before the main reply, and that the same question stays unresolved when the plugin is off.
plugins:
- active-memory

View File

@@ -4,6 +4,9 @@
id: memory-dreaming-sweep
title: Memory dreaming sweep
surface: memory
coverage:
primary:
- memory.dreaming
objective: Verify enabling dreaming creates the managed sweep, stages light and REM artifacts, and consolidates repeated recall signals into durable memory.
successCriteria:
- Dreaming can be enabled and doctor.memory.status reports the managed sweep cron.

View File

@@ -4,6 +4,11 @@
id: memory-failure-fallback
title: Memory failure fallback
surface: memory
coverage:
primary:
- memory.failure-handling
secondary:
- runtime.fallbacks
objective: Verify the agent degrades gracefully when memory tools are unavailable and the answer exists only in memory-backed notes.
successCriteria:
- Memory tools are absent from the effective tool inventory.

View File

@@ -35,6 +35,9 @@
id: memory-recall
title: Memory recall after context switch
surface: memory
coverage:
primary:
- memory.recall
objective: Verify the agent can store a fact, switch topics, then recall the fact accurately later.
successCriteria:
- Agent acknowledges the seeded fact.

View File

@@ -4,6 +4,11 @@
id: memory-tools-channel-context
title: Memory tools in channel context
surface: memory
coverage:
primary:
- memory.tools
secondary:
- channels.group-messages
objective: Verify the agent uses memory_search and memory_get in a shared channel when the answer lives only in memory files, not the live transcript.
successCriteria:
- Agent uses memory_search before answering.

View File

@@ -4,6 +4,11 @@
id: session-memory-ranking
title: Session memory ranking
surface: memory
coverage:
primary:
- memory.ranking
secondary:
- memory.recall
objective: Verify session-transcript memory can outrank stale durable notes and drive the final answer toward the newer fact.
successCriteria:
- Session memory indexing is enabled for the scenario.

View File

@@ -4,6 +4,11 @@
id: thread-memory-isolation
title: Thread memory isolation
surface: memory
coverage:
primary:
- memory.thread-isolation
secondary:
- channels.threads
objective: Verify a memory-backed answer requested inside a thread stays in-thread and does not leak into the root channel.
successCriteria:
- Agent uses memory tools inside the thread.

View File

@@ -4,6 +4,11 @@
id: anthropic-opus-api-key-smoke
title: Anthropic Opus API key smoke
surface: model-provider
coverage:
primary:
- models.provider-auth
secondary:
- models.anthropic
objective: Verify the regular Anthropic Opus lane can complete a quick chat turn using API-key auth.
successCriteria:
- A live-frontier run fails fast unless the selected primary provider is anthropic.

View File

@@ -4,6 +4,11 @@
id: anthropic-opus-setup-token-smoke
title: Anthropic Opus setup-token smoke
surface: model-provider
coverage:
primary:
- models.provider-auth
secondary:
- models.anthropic
objective: Verify the regular Anthropic Opus lane can complete a quick chat turn using setup-token auth.
successCriteria:
- A live-frontier run fails fast unless the selected primary provider is anthropic.

View File

@@ -4,6 +4,11 @@
id: claude-cli-provider-capabilities-subscription
title: Claude CLI provider capabilities subscription
surface: model-provider
coverage:
primary:
- models.provider-capabilities
secondary:
- models.claude-cli
objective: Verify the Claude CLI model-provider lane can use native Claude subscription auth to talk, read an attached image, use bundled MCP tools, and apply workspace skills.
successCriteria:
- A live-frontier run fails fast unless the selected primary provider is claude-cli.

View File

@@ -4,6 +4,11 @@
id: claude-cli-provider-capabilities
title: Claude CLI provider capabilities API key
surface: model-provider
coverage:
primary:
- models.provider-capabilities
secondary:
- models.claude-cli
objective: Verify the Claude CLI model-provider lane can use the Anthropic API key path to talk, read an attached image, use bundled MCP tools, and apply workspace skills.
successCriteria:
- A live-frontier run fails fast unless the selected primary provider is claude-cli.

View File

@@ -4,6 +4,11 @@
id: codex-harness-no-meta-leak
title: Codex harness no meta leak
surface: dm
coverage:
primary:
- models.codex-cli
secondary:
- runtime.no-meta-leak
objective: Verify the Codex app-server harness keeps coordination/meta chatter out of the visible reply.
successCriteria:
- The scenario forces the Codex embedded harness and disables PI fallback.

View File

@@ -4,6 +4,11 @@
id: model-switch-follow-up
title: Model switch follow-up
surface: models
coverage:
primary:
- models.switching
secondary:
- runtime.session-continuity
objective: Verify the agent can switch to a different configured model and continue coherently.
successCriteria:
- Agent reflects the model switch request.

View File

@@ -4,6 +4,11 @@
id: model-switch-tool-continuity
title: Model switch with tool continuity
surface: models
coverage:
primary:
- models.switching
secondary:
- runtime.tool-continuity
objective: Verify switching models preserves session context and tool use instead of dropping into plain-text only behavior.
successCriteria:
- Alternate model is actually requested.

View File

@@ -4,6 +4,11 @@
id: bundled-plugin-skill-runtime
title: Bundled plugin skill runtime
surface: skills
coverage:
primary:
- plugins.skills
secondary:
- plugins.runtime
objective: Verify packaged bundled plugin skills load from dist-runtime instead of being skipped by path-containment checks.
successCriteria:
- The runtime-packaged bundled plugin tree is used as OPENCLAW_BUNDLED_PLUGINS_DIR.

View File

@@ -4,6 +4,11 @@
id: mcp-plugin-tools-call
title: MCP plugin-tools call
surface: mcp
coverage:
primary:
- plugins.mcp-tools
secondary:
- tools.invocation
objective: Verify OpenClaw can expose plugin tools over MCP and a real MCP client can call one successfully.
successCriteria:
- Plugin tools MCP server lists memory_search.

View File

@@ -4,6 +4,11 @@
id: skill-install-hot-availability
title: Skill install hot availability
surface: skills
coverage:
primary:
- plugins.skills
secondary:
- plugins.hot-install
objective: Verify a newly added workspace skill shows up without a broken intermediate state and can influence the next turn immediately.
successCriteria:
- Skill is absent before install.

View File

@@ -4,6 +4,11 @@
id: skill-visibility-invocation
title: Skill visibility and invocation
surface: skills
coverage:
primary:
- plugins.skills
secondary:
- tools.invocation
objective: Verify a workspace skill becomes visible in skills.status and influences the next agent turn.
successCriteria:
- skills.status reports the seeded skill as visible and eligible.

View File

@@ -4,6 +4,11 @@
id: approval-turn-tool-followthrough
title: Approval turn tool followthrough
surface: harness
coverage:
primary:
- runtime.approvals
secondary:
- tools.followthrough
objective: Verify a short approval like "ok do it" triggers immediate tool use instead of fake-progress narration.
successCriteria:
- Agent can keep the pre-action turn brief.

View File

@@ -4,6 +4,11 @@
id: compaction-retry-mutating-tool
title: Compaction retry after mutating tool
surface: runtime
coverage:
primary:
- runtime.compaction
secondary:
- runtime.retry-policy
objective: Verify a real mutating tool step keeps replay-unsafety explicit instead of disappearing into a clean-looking success if the run compacts or retries.
successCriteria:
- Agent reads the seeded large context before it writes.

View File

@@ -4,6 +4,11 @@
id: empty-response-recovery-replay-safe-read
title: Empty-response recovery after replay-safe read
surface: runtime
coverage:
primary:
- runtime.empty-response-recovery
secondary:
- runtime.retry-policy
objective: Verify an empty visible GPT turn after a replay-safe read auto-continues into a visible answer.
successCriteria:
- Scenario is mock-openai only so live lanes do not pick it up implicitly.

View File

@@ -4,6 +4,11 @@
id: empty-response-retry-budget-exhausted
title: Empty-response retry budget exhausted
surface: runtime
coverage:
primary:
- runtime.empty-response-recovery
secondary:
- runtime.retry-policy
objective: Verify repeated empty GPT turns exhaust the retry budget after one continuation attempt.
successCriteria:
- Scenario is mock-openai only so live lanes do not pick it up implicitly.

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