Compare commits

..

282 Commits

Author SHA1 Message Date
Mason Huang
93fc591af9 ci: add process exec codeql security shard 2026-06-13 20:38:21 +08:00
zhang-guiping
4c23d1d597 fix(agents): narrow sessions_send active-run fallback
Limit sessions_send active-run queue delivery to run-scoped targets, keep stranded cron-run fallback for valid cron run keys, and report unsafe queue rejections without rerouting through durable sessions. Fixes #91420.
2026-06-13 20:31:50 +08:00
zhang-guiping
8eb1fa09c6 fix(gateway): reject unknown OpenAI agent selectors
Reject explicit unknown or malformed OpenAI-compatible agent selectors before creating gateway request/session state. Default aliases remain compatible; chat completions, Responses, and embeddings now return structured invalid_request_error responses for bad explicit selectors. Fixes #92504.
2026-06-13 20:16:13 +08:00
Vincent Koc
2d2c1e63f0 test(test): align source full-suite sharding assertion 2026-06-13 19:57:09 +08:00
mushuiyu_xydt
6cdbccaa9e fix #73713: surface nested embedding fetch failures (#92628)
* fix(cli): surface nested fetch failure details

* fix(cli): preserve error prefixes in runtime output
2026-06-13 19:55:37 +08:00
Kailigithub
9f522ee7df fix(copilot): disable eager tool streaming for Claude 4.5 (#75393)
Disable Anthropic eager tool streaming only for GitHub Copilot Claude 4.5 proxy routes, preserving newer Claude behavior.

Fixes #75348

Co-authored-by: Kailigithub <Kailigithub@users.noreply.github.com>
2026-06-13 04:52:02 -07:00
lundog
7404b2b5b4 fix(channels): keep contributed message-tool schema properties optional (#91137)
Co-authored-by: Lundog <jeremy.lundy@lifebeacon.com>
2026-06-13 19:39:01 +08:00
Vincent Koc
73aabcceda fix(test): split local full-suite shards when throttled 2026-06-13 19:36:35 +08:00
dongdong
b1fc8673df fix(anthropic): add Claude Haiku 4.5 catalog entries (#90116)
Add both official Claude Haiku 4.5 API identifiers to the Anthropic static catalog and cover their metadata with a focused regression test.

Fixes #90088

Co-authored-by: Jasmine Zhang <jasminezhang@JasminedeMac-mini.local>
2026-06-13 04:31:57 -07:00
Vincent Koc
4cf4e54179 feat(memory-wiki): import OKF bundles 2026-06-13 19:27:52 +08:00
WhatsSkiLL
84519f7e3c fix(cron): preserve yielded media completions (#92146)
* fix(agents): preserve sessions_yield media pauses

* fix(agents): resume cron media completions

* fix(agents): restore cron media wait predicate import

---------

Co-authored-by: WhatsSkiLL <284122573+IWhatsskill@users.noreply.github.com>
2026-06-13 19:23:25 +08:00
liuhao1024
6314c377bb fix(openrouter): normalize provider-qualified model IDs (#92627)
Normalize provider-qualified OpenRouter model IDs before capability lookup and transport while preserving native OpenRouter namespace IDs.

Fixes #92611.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-06-13 12:05:28 +01:00
Andy Ye
d3e7e03669 fix(ui): preserve WebChat backscroll during streaming (#92622)
* fix(ui): preserve webchat backscroll during streaming

* fix(ui): keep forced chat scroll above follow lock

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-13 18:40:31 +08:00
Vincent Koc
64f9f3c278 fix(models): stabilize vertex marker regression tests 2026-06-13 18:28:50 +08:00
Chunyue Wang
cd3eb438f0 fix(agents): pause yielded subagent runs whose terminal also signals abort (#92631)
* fix(agents): pause yielded subagent runs whose terminal also signals abort (#92448)

* fix(agents): clear yielded subagent grace timers

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-13 18:23:10 +08:00
Alix-007
26281a8a11 fix(slack): diagnose invalid channel map keys (#89438)
Diagnose Slack channel-map keys that cannot route as configured, including account inheritance, open-policy overrides, malformed room identifiers, and DM identifiers.

Fixes #81665

Co-authored-by: Alix-007 <li.long15@xydigit.com>
2026-06-13 03:13:14 -07:00
Vincent Koc
4208c89ec4 test(qa-lab): align bootstrap selection assertion 2026-06-13 18:11:46 +08:00
Peter Steinberger
c9c19a1106 test(models): stabilize plugin auth marker fixtures (#92652)
Stabilizes model auth marker tests against the current manifest-metadata discovery seam and isolated Vitest environment.

Runtime behavior is unchanged; provider-owned non-secret markers remain declared in plugin manifests.
2026-06-13 02:58:02 -07:00
Peter Steinberger
f78d7b52d8 fix: require admin for HTTP session kills (#92651)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-13 02:57:15 -07:00
Ayaan Zaidi
ff6940036b fix(usage): reload missing usage templates (#89835) (thanks @Marvinthebored) 2026-06-13 15:25:14 +05:30
Ayaan Zaidi
b477bfe84b fix(usage): tighten usage footer template handling 2026-06-13 15:25:14 +05:30
Peter Lindsey
d4237cb14d test(vitest): align scoped-config expectation with usage-bar shard include
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:25:14 +05:30
Peter Lindsey
20bc546d94 test(usage): register usage-bar tests in auto-reply-core shard; fix unused-var lint
The usage-bar subdirectory was not covered by any full-suite shard glob
(template.test.ts is disqualified from unit-fast by filesystem-state),
failing the shard coverage check. Extend autoReplyCoreTestInclude to the
subdir; unit-fast-eligible files are auto-excluded by the scoped config.

Also create the temp dir explicitly in the missing-file test instead of
binding an unused tmpFile result, which tripped oxlint no-unused-vars.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:25:14 +05:30
Peter Lindsey
069cb8d636 perf(usage): load the footer template off the reply-delivery path
loadUsageBarTemplate ran a synchronous statSync on every reply (and readFileSync
on mtime change) to check template freshness — synchronous filesystem I/O in the
latency-sensitive reply path, which on a slow / networked / blocked filesystem
can stall /usage full delivery (and the single-threaded event loop with it).

Read the template once into memory and keep it fresh with a filesystem watcher
(persistent: false), so the per-reply path is filesystem-free. A missing file is
not cached (a later-created template is still picked up on a subsequent call); a
watch failure leaves the one-time load in place. Adds template.test.ts covering
inline / file / invalid-JSON / missing-then-created and the FS-free hot path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:25:14 +05:30
Peter Lindsey
8cc5d2d85c feat(usage): render real context occupancy + last-call usage atoms
Consume the usageState contract's contextUsedTokens (populated by #89629) so
the context gauge reflects real end-of-turn window occupancy instead of the
multi-call turn aggregate, which overstates it (often past 100%) and pins the
meter full while /status shows the true figure. Fall back to the aggregate
when contextUsedTokens is absent (single-call turns, where they coincide).

Also expose the final model call's usage as usage.last.* (input/output/cache +
cache_hit_pct) so a template can render the last exchange vs the turn
aggregate.

Adds the consumed fields (contextUsedTokens, lastUsage) to
PluginHookReplyUsageState as the renderer's type dependency; their population
lands in #89629.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:25:14 +05:30
Peter Lindsey
690f27749c feat(usage): add fixed:N decimal-format verb to usage-bar translator
num truncates sub-1000 values to integers, so monetary fields like
cost.turn_usd rendered as 0 (or with full float noise when piped raw).
Add a fixed:N verb that formats a number to N decimal places (default 2),
returning empty string for non-numeric input — matching the other
formatters' guard style.

  {cost.turn_usd|fixed:4}   0.03771985 -> 0.0377

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:25:14 +05:30
Peter Lindsey
7af8153388 fix(usage): clear type-aware oxlint in usage-bar translator
- no-misused-spread: use Array.from for code-point glyph split (same
  behavior, astral glyphs intact) instead of string spread.
- no-base-to-string: return the case glyph only when it is a string.
- no-unnecessary-type-assertion: drop redundant cast already narrowed
  by isObject.

No behavior change (render output byte-identical; tsgo clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:25:14 +05:30
Peter Lindsey
a66a065ffb style(usage): satisfy oxfmt + oxlint for native renderer
Formatting (import order, indentation) and bracket-notation access for
reserved template keys (_aliases/_default) to satisfy no-underscore-dangle.
No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:25:14 +05:30
Peter Lindsey
64d0fc8336 feat(usage): native templated /usage full renderer (retires the footer plugin)
Render the per-reply /usage full footer in core from a declarative template
(the openclaw.usageBar.v1 format) when messages.usageTemplate is set; fall back
to the built-in line otherwise. Ports the reference usage_bar.py engine to TS so
no external process is involved (the external surface is just template data).

- usage-bar/translator.ts: engine (verbs num/dur/pct/inv/alias/meter, segment
  forms text/when/map/each, output.surfaces, item_scales). Codepoint-correct
  glyph indexing; fail-open (empty render -> boring fallback).
- usage-bar/contract.ts: buildUsageContract (snapshot -> openclaw.usageLine.v1).
- usage-bar/template.ts: resolve from a path (mtime-cached) or inline object.
- agent-runner: capture the per-turn usage snapshot and render the template at
  the /usage full branch in place of the built-in line when configured.
- messages.usageTemplate config (string path | inline object) + strict schema.
- translator.test.ts: verb parity, segment forms, astral glyphs, e2e render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:25:14 +05:30
Rohit
f3df863aff fix(gateway): honor profile auth for SecretRef model entries (#90686)
Fixes #90685 by allowing models.list availability to use matching auth-profile credentials when provider config contains a non-env SecretRef, while preserving unavailable results for unresolved SecretRef-only providers.

Adds isolated regression coverage for file SecretRefs and secretref-managed provider markers.

Co-authored-by: Rohit <rohitjavvadi2@gmail.com>
2026-06-13 02:39:06 -07:00
Peter Steinberger
26b9736922 fix: require admin for HTTP model overrides
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-13 02:08:20 -07:00
samson910022
44f45d8729 fix(models): bound model browse discovery (#92247)
Bounds default model browsing to configured/read-only discovery while preserving explicit full-catalog browsing. Reuses prepared plugin metadata and auth state without triggering external CLI discovery on the picker hot path, while retaining provider normalization and canonical runtime aliases.

Verified with focused model tests, official OpenAI and Anthropic transport suites, fresh live tool calls for both providers, a full build, AWS check:changed, remote Docker OpenAI tools E2E, and green PR CI.

Fixes #91809.

Co-authored-by: samson1357924 <98934496+samson1357924@users.noreply.github.com>
2026-06-13 02:04:55 -07:00
Ayaan Zaidi
1c655008cd fix(hooks): tighten reply usage state correlation 2026-06-13 14:25:24 +05:30
Peter Lindsey
618d78144e fix(usage): don't resolve subscription windows for an absent auth signal
A missing per-turn authMode was mapped to "oauth", so an OpenAI api-key turn
that arrived without an explicit auth mechanism could resolve and display
ChatGPT subscription windows that aren't its own — served straight from the 60s
limits cache, which the "re-checked at fetch time" guard does not cover.

Treat a genuinely absent signal as non-eligible (same as api-key): no usage
provider resolves and the footer omits limit windows. Present mechanisms are
unchanged — oauth/auth-profile/token stay eligible, and only OpenAI is gated on
the credential type so other providers are unaffected. A real oauth/profile turn
always carries its mechanism; one arriving blank is an upstream tagging bug to
fix at the source.

Inverts the now-incorrect "absent => oauth-eligible" test into regression
coverage for the absent/api-key case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:25:24 +05:30
Peter Lindsey
56f2102c28 fix(usage): thread real context occupancy + last-call usage into usageState
context.used_tokens / pct_used were derived from the snapshot's aggregate
prompt total (cacheRead+cacheWrite+input). Over a multi-call tool-loop turn
that is the run AGGREGATE, overstating window occupancy (often past 100%) so a
footer's context gauge pins full while /status shows the true ~7%.

Add two optional fields to PluginHookReplyUsageState and populate them in the
reply path:
- contextUsedTokens: the final call's prompt size (agentMeta.promptTokens) =
  real end-of-turn occupancy, a point-in-time state, not the aggregate.
- lastUsage: the final model call's usage only (vs `usage`, the turn
  aggregate), so a footer can render the last exchange's i/o + cache.

Both optional and additive; consumers fall back to the aggregate when absent
(correct for single-call turns). Renderer consumption lands separately (#89835).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:25:24 +05:30
Peter Lindsey
99db98a7ce fix(usage): map auth mechanism to usage-credential type for provider limits
requestShaping.authMode is the auth *mechanism* (e.g. "auth-profile" for a
configured auth profile), not the credential *type* resolveUsageProviderId
expects. Gating limits on it === "oauth"/"token" dropped 📊 for legit OAuth
(profile-based) turns. Map it: api-key/aws-sdk -> no usage provider (cannot
borrow cached oauth windows); oauth/token/auth-profile/absent -> usage-eligible,
with the real credential re-checked at fetch time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:25:24 +05:30
Peter Lindsey
0dbfa1f6be fix(hooks): make usageState limits credential-aware + document best-effort delivery
Addresses review feedback on #89629.

1) Provider-limit resolution no longer defaults to OAuth. getProviderUsageLimits
   and getProviderUsageLimitsCached resolved with `credentialType ?? "oauth"`, and
   the agent-runner snapshot call passed no credential type, so an api-key OpenAI
   turn could borrow cached OAuth/ChatGPT usage windows. Drop the "oauth" default
   (missing credential type => no OpenAI usage provider) and thread the turn's
   authMode through at the call site. Adds provider-usage.limits.test.ts covering
   api-key/no-credential (no fetch), oauth/token (resolves), and non-OpenAI.

2) usageState is documented as best-effort, present only on live dispatcher
   delivery. Routed durable and recovered queue replays re-run this hook as a
   stateless transform over the original payload (see QueuedDeliveryPayload); a
   point-in-time usage snapshot is not stateless and would replay stale after a
   restart, so it is intentionally omitted there. Consumers must treat the field
   as optional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:25:24 +05:30
Peter Lindsey
f06f2f17c2 feat(hooks): per-turn usageState (with provider limits + rich atoms) on reply_payload_sending
Attach a per-turn execution snapshot to the reply_payload_sending hook as
`usageState`, so a plugin (or the future in-core /usage renderer) can render a
per-response usage readout as a pure consumer of the contract — no side calls.

Recorded in agent-runner, consumed in dispatch. Fields: provider, model,
resolvedRef/requested, reasoningEffort, fastMode, fallbackUsed, is_override
(overrideSource), authMode, compactionCount, contextTokenBudget, token usage,
turn cost (USD), duration, owning agentId/sessionId, chatType, the agent
identity (name/emoji), and the active provider's subscription `limits` windows.

reply_payload_sending is the one reply hook universal across every surface
(incl. the Codex app-server, which emits no llm_output/agent_end), so it is the
correct harness-agnostic place for per-turn usage. Limits are resolved by a
core-internal non-blocking SWR helper (src/infra/provider-usage.limits.ts) and
attached to the snapshot — no new plugin-SDK accessor. All fields optional/additive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:25:24 +05:30
BunsDev
7bd533a80e Revert "chore(maint): make PR changelog edits release-only (#92607)"
This reverts commit 4640baa299.
2026-06-13 03:10:10 -05:00
Dallin Romney
561b293c7a Run Vitest and Playwright scenarios from qa suite (#92606)
* test(qa): run vitest and playwright scenarios from qa suite

* fix(qa): harden scenario suite dispatch

* refactor(qa): share scenario path utilities

* refactor(qa): share test file scenario runner

* refactor(qa): route test file scenarios through suite runtime

* refactor(qa): use explicit suite runtime result kind

* test(qa): write suite evidence artifact

* refactor(qa): clarify suite execution dispatch

* fix(qa): keep test-file scenarios out of flow-only runners

* refactor(qa): export mixed scenario suite runner
2026-06-13 01:06:10 -07:00
zhangqueping
21aa8faf8a #92589: fix(internal-runtime-context): wrap prompt-preface runtime context body in delimiters (#92593)
* fix(internal-runtime-context): wrap prompt-preface runtime context body in delimiters

When buildRuntimeContextMessageContent constructs the hidden
runtime context prompt block, the body (which may contain
sensitive metadata like relevant-memories, sender info, and
conversation metadata) was not wrapped in the standard
INTERNAL_RUNTIME_CONTEXT_BEGIN/END delimiters.  If the model
echoed this context back in its reply, stripInternalRuntimeContext
could only remove the header and notice lines — the sensitive
body leaked through to user-visible surfaces like Feishu
streaming cards.

Wrap the runtime context body in BEGIN/END delimiters so the
existing stripInternalRuntimeContext (which handles delimited
blocks first) can fully remove the entire block.

Closes #92589

* chore: retrigger CI for proof check

* chore: retrigger CI with corrected proof format

* chore: retrigger CI with corrected proof field format
2026-06-13 02:59:28 -05:00
lizeyu-xydt
b0bd9c8ed8 fix(docs): pin Windows Hub download links to v2026.6.5 (#92605)
The Windows Hub companion installers are promoted to the main OpenClaw
release via a manual workflow_dispatch, not every release includes them.
The /releases/latest/download/ links resolved to v2026.6.6 which does not
have the OpenClawCompanion assets, causing 404 errors.

Pin the links to v2026.6.5 (the latest release that has the assets) and
add a fallback note directing users to the releases page when a release
is missing the companion installers.

Fixes #92470
2026-06-13 02:59:22 -05:00
liuhao1024
c5d599c8c4 docs(gateway): add uptime monitoring guidance to health check docs (fixes #55768) (#92608) 2026-06-13 02:59:18 -05:00
mushuiyu_xydt
3a1a5c0dac fix(memory): preserve qmd startup errors (#92618) 2026-06-13 02:59:14 -05:00
Val Alexander
0849cac106 fix(a11y): B-1+B-2+B-3 — contrast, focus states, minimum font sizes (#89822)
* fix(a11y): B-1 — raise muted text contrast to ≥4.8:1 WCAG AA

* fix(ui): C-1 — ChatSidePanel joins glass surface language

* feat(mobile): D-1 — hamburger overlay nav below 900px

- Esc key now closes nav drawer (globalKeydownHandler)
- Nav item tap targets bumped to min-height: 44px + padding: 10px 16px
  in the ≤1100px drawer breakpoint (was 40px / 0 12px)
- Hamburger toggle + overlay drawer were already wired in app-render.ts;
  this completes close-on-Esc and ensures accessible tap targets

* fix(a11y): B-2 — consistent focus-visible states distinct from hover

* fix(a11y): B-3 — lift all sub-12px text to 12px minimum

* fix(a11y): narrow focus and CSS scope

* fix(a11y): finish focus-visible selectors
2026-06-13 02:57:17 -05:00
Val Alexander
32ce06daf8 feat(ui): hide empty workboard columns (#89615) 2026-06-13 02:57:14 -05:00
Val Alexander
751d3db1cc docs: UX-013 — design system documentation (#89827)
* docs: UX-013 — shared design system documentation

* docs: align design system docs with source tokens
2026-06-13 02:57:11 -05:00
Val Alexander
4640baa299 chore(maint): make PR changelog edits release-only (#92607) 2026-06-13 02:57:08 -05:00
tangtaizong666
c9f0bfd476 fix(agents): isolate invalid plugin model catalogs (#92564)
Keep valid root and plugin models available when one generated plugin catalog is invalid, while retaining and logging the catalog error.

Fixes #92553.

Co-authored-by: tangtaizong666 <tangtaizhong792@gmail.com>
2026-06-13 00:52:52 -07:00
Hansraj Singh Thakur
ab559a7257 fix(channels): report progress draft startup failures (#92083)
Report timer-fired channel progress-draft startup failures at the shared gate boundary instead of silently dropping them. Explicitly awaited starts still reject, and failed timer starts remain retryable.

Refs #92031.

Co-authored-by: Hansraj Singh Thakur <hansraj136@gmail.com>
2026-06-13 00:42:13 -07:00
Andy Ye
7c08804541 fix(slack): persist delivered replies in transcripts (#92498)
Persist successful same-channel Slack and CLI assistant replies exactly once in the owning transcript. Preserve delivery-hook output, routed/runtime ownership, custom stores, and authoritative reset/session rotation bindings.

Fixes #92489

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-06-13 00:17:16 -07:00
Vincent Koc
991471b8ec fix(mistral): skip unreadable tool schemas (#90242)
Skip unreadable Mistral tool schemas while preserving valid sibling tools. Omit empty tool payloads, reject forced choices for removed tools, and snapshot pinned tool names before validation and emission.

Live Mistral E2E: run_9b34d30e9f5b
CI: https://github.com/openclaw/openclaw/actions/runs/27459679633

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-13 00:00:52 -07:00
brokemac79
7190fc4de8 fix(doctor): diagnose blocked external channel plugins (#86629)
Diagnose configured channel plugins whose installed owners are blocked by trust or activation policy, while preserving multi-owner fallback and actionable channel safety warnings.

Closes #83212.

Co-authored-by: luyifan <al3060388206@gmail.com>
2026-06-12 23:24:19 -07:00
Evgeni Obuchowski
9d9389bc6b fix(fireworks): resolve catalog model params from manifest (#90326)
Resolve bundled Fireworks manifest models through core's static catalog so Kimi K2.6 keeps its 262,144-token context limit and nested model compatibility metadata.

Keep the existing dynamic fallback for uncataloged Fireworks IDs and align bundled Kimi reasoning metadata with existing runtime behavior.

Verified with focused tests, extension/core type checks, lint/format, full build, fresh autoreview, required CI, and a live Fireworks Kimi K2.6 embedded run using a real key.

Co-authored-by: Evgeni Obuchowski <evgeni@obukhovski.com>
2026-06-12 22:48:46 -07:00
Jason (Json)
6c88811b4b Expose paged channel action results (#88993)
* fix(discord): expose paged thread list results

* fix(discord): keep thread pagination local

* fix(discord): use archive timestamps for thread cursors

* test(discord): cover thread pagination results

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-12 21:29:43 -07:00
Rain
4a6666796f fix(moonshot): rewrite duplicate native Kimi tool call ids
Preserve the first native Kimi tool-call ID while rewriting repeated replay occurrences to deterministic OpenAI-style IDs and keeping paired tool results aligned. Moonshot responses-family behavior and providers that do not opt in remain unchanged.

Closes #51593

Co-authored-by: Pluviobyte <Pluviobyte@users.noreply.github.com>
2026-06-12 21:14:03 -07:00
Peter Steinberger
68222ba5e3 fix: harden websocket payload handling 2026-06-12 21:03:10 -07:00
ragesaq
1bd783045b fix(gateway): mirror commentary-phase assistant events to session message subscribers
Non-control-UI-visible runs previously dropped assistant commentary on the
floor for session message subscribers. Mirror those events to exact session
subscribers, gated strictly on phase === "commentary" so untagged text or
delta frames and final-answer streaming never dual-lane into channel
surfaces. Dialects that emit commentary as untagged deltas should tag the
phase at provider normalization instead.

Co-authored-by: Forge <forge@psiclawops.dev>
Co-authored-by: Chisel <chisel@psiclawops.dev>
2026-06-13 09:31:53 +05:30
Patrick Erichsen
6cf06e8e7e ci: split plugin ClawHub publishing paths
* feat: partition clawhub plugin release candidates

* fix: read clawhub trusted publisher config endpoint

* feat: split clawhub plugin bootstrap workflow

* ci: split plugin clawhub publish paths

* ci: pin clawhub package publish workflow

* ci: keep clawhub bootstrap token out of builds

* ci: fix clawhub release dry-run gating

* ci: align clawhub oidc publish refs

* ci: make clawhub bootstrap recovery idempotent

* ci: route clawhub repair candidates through bootstrap

* ci: preserve tideclaw alpha clawhub guards

* ci: simplify clawhub release ref handling

* ci: extract clawhub release routing plan

* ci: extract clawhub release runtime state

* test: guard clawhub release helper executability

* ci: pin ClawHub CLI for plugin publishing

* ci: allow historical ClawHub dry-run validation

* ci: fix ClawHub bootstrap token handoff
2026-06-12 20:16:06 -07:00
Dallin Romney
ded3a93058 fix(e2e): keep lifecycle timeout cleanup alive (#92566) 2026-06-12 18:52:34 -07:00
Peter Lee
0063f3076c fix(moonshot): backfill reasoning_content on assistant tool-call replay messages (#92396)
Moonshot/Kimi requires reasoning_content on all assistant tool-call messages
when thinking is enabled. After LCM compaction, cross-model fallback, or
session repair, the replayed history may be missing this field, causing a
400 error from the Moonshot API.

Backfill an empty string to satisfy the API schema contract without
fabricating semantic reasoning content. Follows the same provider-owned
backfill pattern already used by Kimi Coding (extensions/kimi-coding/stream.ts)
and DeepSeek V4 (provider-stream-shared.ts).

Fixes #71491

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 17:58:43 -07:00
Peter Steinberger
8c7e5c6918 feat(moonshot): add Kimi K2.7 Code support (#92554)
* feat(moonshot): add Kimi K2.7 Code support

* test(moonshot): surface K2.7 live provider errors

* ci(live): accept Kimi key for Moonshot sweeps

* test(moonshot): verify K2.7 across API regions
2026-06-12 17:37:28 -07:00
Shakker
e338037034 test: cover telegram expandable blockquotes 2026-06-13 01:29:27 +01:00
Jamil Zakirov
05796759ad fix(telegram): allow expandable blockquotes 2026-06-13 01:29:27 +01:00
Dallin Romney
d8b3e523ff Add QA scorecard taxonomy validation (#91500)
Merged via squash.

Prepared head SHA: a9aec907d4
Co-authored-by: RomneyDa <6581799+RomneyDa@users.noreply.github.com>
Co-authored-by: RomneyDa <6581799+RomneyDa@users.noreply.github.com>
Reviewed-by: @RomneyDa
2026-06-12 17:07:51 -07:00
Dallin Romney
4809ac70fa Add QA evidence artifact output (#91484)
* feat: add qa evidence summary normalization

* chore: rename qa evidence target environment

* chore: align qa evidence profile terminology

* chore: align qa evidence summary fields

* chore: add qa evidence taxonomy ref

* test: remove stale multipass evidence example

* test(qa): normalize vitest and playwright evidence

* test(qa): slim evidence summary metadata

* test(qa): clarify evidence summary inputs

* test(qa): rename scenario specs in evidence flow

* test(qa): treat evidence profiles as mapping strings

* test(qa): use neutral evidence test identity

* test(qa): nest evidence summary joins

* refactor(qa): normalize live evidence summaries

* fix(qa): accept normalized telegram rtt summaries

* fix(qa): normalize evidence lane summaries

* fix(qa): align evidence summaries with requirements

* refactor(qa): tighten evidence summary builders

* refactor(qa): restore standard evidence ids

* fix(qa): keep legacy summaries out of rtt evidence

* refactor(qa): make package evidence provenance explicit

* test(qa): keep script tests out of qa lab internals

* refactor(qa): rename scenario evidence definitions

* refactor(qa): clean evidence summary wording

* test(qa): fix evidence summary test inputs

* refactor(qa): simplify evidence identity fields

* refactor(qa): tighten evidence summary inputs

* refactor(qa): rename evidence artifact
2026-06-12 16:12:58 -07:00
Dallin Romney
777edadb36 fix: update esbuild audit pin (#92540) 2026-06-12 15:36:49 -07:00
brokemac79
8d9ce35b92 fix(sandbox): render cli skill prompts from materialized paths (#92508) 2026-06-12 16:59:32 -04:00
xydigit-sj
69bf333dde fix(outbound): honor top-level image param as send media source (#92407) (#92416)
When a message send action included an `image` media-source param, the shared outbound runner recognized it for sandbox validation and media-access hints but then omitted it from the generic send payload, causing text-only delivery with a silent ok:true result.

Add `image` to the mediaHint resolution chain in buildSendPayloadParts so it is treated as a first-class media source for send only, preserving action-specific image semantics for non-send actions. Add regression coverage.

Fixes #92407.
2026-06-12 16:09:45 -04:00
Shakker
e3a6da0f51 test: tighten doctor update progress coverage 2026-06-12 17:58:28 +01:00
Amer Sheeny
8ec1c0676b fix(doctor): drop redundant Boolean conversion flagged by oxlint 2026-06-12 17:58:28 +01:00
Amer Sheeny
e4b6b9ea66 test(doctor): cover update progress wiring and spinner cleanup 2026-06-12 17:58:28 +01:00
Amer Sheeny
aba3751ad7 fix(doctor): show per-step progress spinners during update 2026-06-12 17:58:28 +01:00
Josh Avant
9921825e17 Fix Telegram spooled buffered replay (#92281)
* fix telegram spooled buffered replay

* fix telegram replay type checks

* fix telegram replay lint

* test telegram replay visible output retry guard

* fix telegram rollback failure retry
2026-06-12 11:51:46 -05:00
Josh Avant
652e616a29 fix: repair rejected Anthropic thinking replay (#92286)
* fix: repair rejected Anthropic thinking replay

* fix: narrow recovered retry result

* test: satisfy thinking recovery lint

* test: prove thinking retry preserves fresh reasoning

* test: type narrow thinking retry proof
2026-06-12 11:48:19 -05:00
Josh Avant
f385491c23 fix: clarify gateway SecretRef auth diagnostics (#92290)
* fix gateway secretref health diagnostics

* fix gateway health result type narrowing
2026-06-12 11:18:22 -05:00
Ruben Cuevas Menendez
7387083a95 fix(codex): preserve memory prompt registration (#92350)
* fix(codex): restore memory recall guidance

* fix(codex): add memory recall fallback

* fix(codex): preserve memory prompt registration

* test(codex): expect memory slot in scoped harness load

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-12 12:17:02 -04:00
Josh Avant
462092936a fix(agent): continue after source message tool replies (#92343) 2026-06-12 11:14:57 -05:00
Josh Avant
da4671ebcc fix provider static model fallback (#92293) 2026-06-12 11:14:00 -05:00
Josh Avant
9386d6214f fix: resolve managed secretref provider auth (#92235) 2026-06-12 10:59:04 -05:00
Josh Avant
8673c65c6b fix(update): hand off Linux service auto-updates (#92282) 2026-06-12 10:55:21 -05:00
Josh Avant
f3eb8e9714 Fix OTLP log trace correlation (#92276)
* fix diagnostics otel log trace correlation

* test diagnostics trace provenance contract
2026-06-12 10:54:21 -05:00
Josh Avant
f80f472190 fix(agents): classify structured unsupported model errors (#92280)
* fix(agents): classify structured unsupported model errors

* test(agents): update embedded harness helper mock
2026-06-12 10:35:39 -05:00
Josh Avant
3643de4ba7 fix heartbeat suppressed commitment delivery (#92231) 2026-06-12 10:13:13 -05:00
Josh Avant
41a9277844 fix: fail closed for cli-backed btw fallback (#92226) 2026-06-12 10:11:35 -05:00
Josh Avant
79901fb4ba fix: inherit static transport for configured DeepSeek models (#92265) 2026-06-12 10:09:53 -05:00
Josh Avant
e728957989 Fix disabled heartbeat one-shot cron retries (#92225)
* fix: retry disabled cron wake one-shots

* fix: satisfy cron retry CI checks
2026-06-12 09:54:33 -05:00
Josh Avant
d9124c9700 fix doctor channel SecretRef preview (#92229) 2026-06-12 09:50:31 -05:00
Shakker
81c553e2fb fix: stop docker build commands by pid and group 2026-06-12 15:16:00 +01:00
Chunyue Wang
0fc5a57a34 fix(anthropic-vertex): stop re-marking cache_control on transport-budgeted payloads (#92387)
Summary:
- The PR removes the Anthropic Vertex adapter’s redundant cache-control payload-policy pass, forwards caller payload hooks unchanged, and adds regressions for preserving transport-budgeted payloads.
- PR surface: Source -35, Tests -11. Total -46 across 2 files.
- Reproducibility: yes. at source level. Current main reapplies cache policy to a finalized, fully budgeted pa ... ion logs show the corresponding five-marker rejection; this review did not run a live post-fix GCP request.

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

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

Prepared head SHA: 6ef19602bf
Review: https://github.com/openclaw/openclaw/pull/92387#issuecomment-4688955121

Co-authored-by: openperf <16864032@qq.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-12 12:59:02 +00:00
Shakker
1bd04ac983 fix: route respawn hint env clears 2026-06-12 12:29:00 +01:00
Shakker
294779e5d6 test: scope plugin dispatch state env 2026-06-12 12:26:41 +01:00
Shakker
888835cfe6 fix: route live env restore deletes 2026-06-12 12:24:55 +01:00
Shakker
a716950a3c test: route state dir env helper 2026-06-12 12:22:12 +01:00
Shakker
667bc2c4ca fix: scope commitment heartbeat state env 2026-06-12 12:20:15 +01:00
Shakker
01b004c594 test: scope commitment full chain state env 2026-06-12 12:18:01 +01:00
Shakker
9cf1ef1d90 fix: restore commitment extraction state env 2026-06-12 12:15:07 +01:00
Shakker
1c5099803f test: restore commitment runtime state env 2026-06-12 12:12:56 +01:00
Shakker
0d4968d466 fix: restore commitment store state env 2026-06-12 12:10:46 +01:00
Shakker
0efe5857bc test: scope doctor missing state env 2026-06-12 12:08:44 +01:00
Shakker
fed2c36611 fix: add test env delete helper 2026-06-12 12:06:12 +01:00
Shakker
bcc1105b30 test: scope reply session state env 2026-06-12 12:03:40 +01:00
Shakker
b750d314b7 fix: scope image inbound state env 2026-06-12 12:00:59 +01:00
Ayaan Zaidi
d4819948f3 fix(telegram): restart isolated polling on getUpdates conflict and surface it in status 2026-06-12 10:29:56 +05:30
Ayaan Zaidi
4a3d06ee37 fix(telegram): carry bot api error codes across the ingress worker boundary 2026-06-12 10:29:56 +05:30
Ayaan Zaidi
ff04e24ead fix(telegram): retry transient draft preview failures instead of killing the stream 2026-06-12 10:18:11 +05:30
Ayaan Zaidi
a956ab8481 refactor(telegram): centralize edit error classification in network-errors 2026-06-12 10:18:11 +05:30
Vincent Koc
3b78d41a9e fix(release): use trusted publishing for plugin npm 2026-06-12 12:07:32 +08:00
Vincent Koc
3c9c4aa428 fix(docs): remove stale ClawHub nav page 2026-06-12 12:07:32 +08:00
Jesse Merhi
6223a538bc fix(docker): bundle QA Lab runtime in the image (#92087)
* fix(docker): split qa lab runtime fixes

* fix(docker): remove store platform selector

* test(docker): assert qa lab ui copy is gated
2026-06-12 14:02:32 +10:00
liuhao1024
8be3beec74 fix(cron): preserve timezone on expression edits
Fixes #92291.

Preserves the existing cron timezone when `cron edit <id> --cron ...` replaces only the expression, while avoiding stale stagger writes and preserving API/UI omitted-timezone clearing semantics.

Proof:
- node scripts/run-vitest.mjs src/cli/cron-cli/register.cron-edit.test.ts src/cli/cron-cli.test.ts src/cron/service.jobs.test.ts src/cron/normalize.test.ts
- node scripts/run-oxlint.mjs src/cli/cron-cli/register.cron-edit.ts src/cli/cron-cli/register.cron-edit.test.ts src/cli/cron-cli.test.ts src/cron/service/jobs.ts src/cron/service.jobs.test.ts
- git diff --check origin/main...HEAD
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- Azure Crabbox cbx_3ce6a4e0d945 / silver-lobster: OPENCLAW_TESTBOX=1 node scripts/crabbox-wrapper.mjs run --provider azure --class Standard_D4ads_v6 --idle-timeout 90m --ttl 240m --timing-json -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed
2026-06-12 11:33:52 +08:00
mushuiyu_xydt
6a2ec62865 fix(daemon): keep unsupported service status readable
Fixes #25621.\n\nKeep gateway status readable on unsupported service-manager platforms by returning a conservative read-only service adapter, while lifecycle mutations still reject clearly. Includes regression coverage for resolver, status, summary, and lifecycle behavior.\n\nVerified with focused Vitest/oxlint/diff checks, autoreview, and Azure Crabbox check:changed on lanes core/coreTests.
2026-06-12 12:05:22 +09:00
Galin Iliev
301213a05f test(sqlite): add state perf query plan harness
Adds a SQLite state query-plan regression test and smoke benchmark, wires the smoke artifact into source performance evidence, validates SQLite smoke output in the performance summary, and removes a retired ClawHub nav entry that broke docs link checks.

Fixes #91616
2026-06-11 14:49:26 -07:00
Patrick Erichsen
9827490f5f fix: rely on ClawHub plugin publish checks 2026-06-11 11:51:57 -07:00
Shakker
575cae59d4 fix: preserve utils exports in doctor health tests 2026-06-11 17:32:25 +01:00
Shakker
4e4dc10db0 fix: avoid support write shadowed variable 2026-06-11 17:32:25 +01:00
Shakker
34a1102506 fix: preflight skill writes before rollback metadata 2026-06-11 17:32:25 +01:00
Shakker
1156ab637c refactor: move workspace skill writes to lifecycle 2026-06-11 17:32:25 +01:00
Shakker
0fc2faa0f4 test: narrow whatsapp mention fixture 2026-06-11 17:29:24 +01:00
Shakker
fdf3667e09 test: clean whatsapp alias assertions 2026-06-11 17:29:24 +01:00
Shakker
de9260f813 fix: keep whatsapp inbound aliases live 2026-06-11 17:29:24 +01:00
Marcus Castro
4d45884419 docs(plugins): record whatsapp inbound compatibility 2026-06-11 17:29:24 +01:00
Marcus Castro
1bea7d8ef3 test(whatsapp): cover inbound context compatibility 2026-06-11 17:29:24 +01:00
Marcus Castro
008d785a80 test(whatsapp): update auto reply inbound fixtures 2026-06-11 17:29:24 +01:00
Marcus Castro
eebcb100b8 refactor(whatsapp): read inbound contexts in auto reply 2026-06-11 17:29:24 +01:00
Marcus Castro
b5295a6a34 refactor(whatsapp): introduce inbound message contexts 2026-06-11 17:29:24 +01:00
Shakker
3d6252a517 test: harden stalled websocket cleanup 2026-06-11 15:32:58 +01:00
Shakker
c4d3f0545c fix: validate workshop support symlink writes 2026-06-11 15:20:38 +01:00
Shakker
6b0525f237 fix: gate Skill Workshop symlink writes 2026-06-11 15:20:38 +01:00
abnershang
287b10a895 feat(skills): allow trusted workshop symlink targets 2026-06-11 15:20:38 +01:00
Sally O'Malley
ea813a2476 fix: handle explicit silent assistant replies (#92073)
Signed-off-by: sallyom <somalley@redhat.com>
2026-06-11 10:20:08 -04:00
Matt H
9be1699074 fix(wizard): report keyless web search providers as ready
Onboarding finalize now treats configured web search providers with requiresCredential: false as ready instead of warning that an API key is missing. This covers keyless providers such as Parallel Search (Free), DuckDuckGo, and Ollama while preserving credential-required warnings for providers that need keys.\n\nProof: focused wizard/search tests; oxlint on changed files; git diff --check; autoreview clean; Azure Crabbox check:changed cbx_b92ef084c21c passed; GitHub checks green.
2026-06-11 23:18:49 +09:00
mushuiyu_xydt
777f7409d8 fix(installer): stop after failed Node package installs
Linux Node package-manager setup/install failures now fail the installer immediately instead of falling through to a misleading success path. Adds regression coverage for NodeSource setup and apt nodejs install failures under conditional shell invocation.\n\nFixes #73837\n\nProof: bash -n scripts/install.sh; node scripts/run-vitest.mjs test/scripts/install-sh.test.ts; node scripts/run-oxlint.mjs test/scripts/install-sh.test.ts; git diff --check origin/main...HEAD; autoreview clean; Azure Crabbox check:changed cbx_6286dc1e287b passed.
2026-06-11 22:58:43 +09:00
clawsweeper[bot]
2bec2caf0c fix(channel): harden local setup trust (#92175)
Summary:
- The PR extends channel setup trust enforcement and trusted catalog fallback from workspace-origin plugins to ... nfigured load paths into catalog discovery, and adds focused regression plus Docker/package proof coverage.
- PR surface: Source +190, Tests +892, Other +324. Total +1406 across 13 files.
- Reproducibility: yes. The source PR provides a concrete clean-main Docker/package path where an explicitly t ... ns unresolved, while the patched package resolves it and still blocks untrusted module and setup execution.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(channel): stabilize trusted catalog dts typing
- PR branch already contained follow-up commit before automerge: fix(channel): repair trusted catalog exclusions typing
- PR branch already contained follow-up commit before automerge: test(channel): cover local channel plugin trust
- PR branch already contained follow-up commit before automerge: chore(deps): refresh plugin shrinkwraps
- PR branch already contained follow-up commit before automerge: test(channel): route trust regression in command shard
- PR branch already contained follow-up commit before automerge: test(channel): remove e2e-named trust regression

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

Prepared head SHA: eabee04d54
Review: https://github.com/openclaw/openclaw/pull/92175#issuecomment-4680798117

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-11 13:48:41 +00:00
liuhao1024
047785eb30 fix(cron): report SQLite storage path in cron.status instead of legacy jobs.json (#92144)
* fix(cron): report SQLite storage path in cron.status instead of legacy jobs.json

The `cron.status` gateway response returned `storePath` pointing to the
legacy `jobs.json` path, but cron jobs are actually stored in the shared
SQLite state database. This misled operators and agents into looking for
a JSON file that no longer exists.

- Add `storage: "sqlite"` and `sqlitePath` fields to CronStatusSummary
- Mark legacy `storePath` as @deprecated (kept for backward compat)
- Update CLI warning to prefer sqlitePath over storePath
- Add regression assertions in read-ops test

Fixes #91766

* fix(macos): prefer sqlitePath in cron status display

* fix(macos): add sqlitePath to CronSchedulerStatus type
2026-06-11 22:39:42 +09:00
Shakker
f7ee25291a chore: remove redundant proof scripts 2026-06-11 14:29:12 +01:00
Vincent Koc
79d7defd0b test(ci): relax docker signal wait 2026-06-11 21:52:19 +09:00
Vincent Koc
68ec783e74 fix(agents): project thinking catalog compat 2026-06-11 21:14:50 +09:00
兰之
9a6c71a47d fix(agents): retry same model across short rate-limit windows (#91911)
Bound same-model rate-limit retries to explicit short-window signals or parsed short Retry-After values, honor Retry-After in the retry sleep, preserve zero-rotation fallback behavior, and record same-model rate-limit retries separately from profile rotations.

Verification:
- node scripts/run-vitest.mjs src/agents/embedded-agent-runner/run/assistant-failover.test.ts src/agents/embedded-agent-runner/run/helpers.test.ts
- Azure Crabbox cbx_bdb5a7807a1f / coral-shrimp: OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
2026-06-11 21:00:49 +09:00
openclaw-clownfish[bot]
99d0bdc23a fix(cli): validate gateway RPC timeout inputs
Reject malformed or explicit empty Gateway RPC timeout values before opening Gateway calls, align the shared Gateway RPC omitted-timeout fallback with the 30000 ms CLI default, and validate explicit `cron add --timeout-seconds` values at the CLI boundary.

Carries forward the useful source work from #54646 and the earlier timeout-validation context from #40953. #60661 remains separate accepted-run timeout semantics work and is intentionally not folded into this change.

Validation:
- `npm run review-results -- /tmp/clownfish-check-27341769444`
- `git diff --check`
- OpenClaw PR checks on `ce7bd8b9388a5689b14ddc2b3a984f7b4647e5ca`: 132 pass, 0 pending, 0 failing
- ClawSweeper re-review: https://github.com/openclaw/clawsweeper/actions/runs/27344244608

Co-authored-by: RayRuan <43744645+ruanrrn@users.noreply.github.com>
Co-authored-by: Homeran <11574611+comeran@users.noreply.github.com>
2026-06-11 20:52:07 +09:00
Vincent Koc
6fb0c940fa fix(release): gate beta publish on plugin verification
Delay public GitHub release publication until postpublish verification, dependency evidence upload, proof append, and required plugin publish gates pass.

Also updates release-maintainer instructions so newly publishable plugins are minted/prepublished through an owner-approved path without consuming the next auto-bumped beta version unless that path is the actual release publish.
2026-06-11 20:42:58 +09:00
Ben Newell
cdb55b3edb fix(memory-core): retry narrative message reads (#89091)
* fix(memory-core): retry narrative message reads

* fix(memory-core): wait longer for narrative text
2026-06-11 20:36:18 +09:00
Dream Hunter
8d72cb9401 fix(memory): abort orphaned embedding work when memory_search times out (#91742)
* fix(memory): abort orphaned embedding work when memory_search times out

memory_search raced its 15s deadline with Promise.race and returned a clean
timeout to the agent, but the underlying embedQueryWithRetry loop kept
retrying (3 attempts x 60s) against the embedding backend with no consumer.
Thread the tool-owned AbortSignal through manager.search ->
embedQueryWithRetry -> runEmbeddingOperationWithTimeout so the deadline
cancels in-flight embedding work, stops the retry loop, and skips
fallback-provider activation for an absent caller.

Fixes #91718

* fix(memory): let the deadline result win before aborting the search

Abort listeners dispatch synchronously, so an abort-aware search could
reject the raced task before the timeout promise resolved and replace the
stable 'memory_search timed out after 15s' result with a provider-wrapped
abort error. Resolve the timeout first, then abort.

* fix(memory): scope deadline abort to builtin embeddings

* fix(memory): preserve deadline signal across fallback

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 20:36:11 +09:00
Chunyue Wang
4f3c2cd2df fix(ollama): use provider thinking default in SDK session factory (#91657)
* fix(ollama): use provider thinking default in SDK session factory

* fix(agents): preserve model metadata for thinking defaults

* fix(agents): resolve custom Ollama thinking policy

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 20:36:05 +09:00
Evgeni Obuchowski
16ea3f272f fix(plugins): stop derived metadata snapshot rescan storm in /models (regression shipped since v2026.5.18) (#92127)
Since 5734193fdf ("fix(plugins): keep metadata snapshot memo fresh",
first shipped in v2026.5.18), the in-process plugin metadata snapshot
memo stores derived-registry results under a key recomputed from the
freshly built snapshot.index, while lookups key off the persisted-index
registry state. On installs where the registry resolves as "derived"
(persisted index absent or not covering the running checkout), the two
keys never match. Worse, the lookup-side adoption loop returns the most
recently stored registryState for the context, so two alternating call
shapes (e.g. the model-catalog build mixing workspace-scoped and global
lookups) each adopt the other shape's state, compute a key that was
never stored, and re-run the full plugin manifest scan - on every call,
forever. Chat /models (and each subsequent provider/model pick) pays
multiple full manifest scans plus all downstream snapshot-identity cache
invalidation per step, pinning a CPU core for seconds on every
interaction, in every chat channel.

Fix: store the memo under the exact memoKey/registryState the call
looked up by, instead of re-deriving a second key from snapshot.index.
Freshness is unchanged - the lookup context hash and the plugin metadata
lifecycle clears (install/reload/doctor) still own invalidation. The
now-unused index parameter of resolvePersistedRegistryMemoState is
removed.

Measured on the author's VPS (real plugin discovery, identical catalog
output of 263 entries on both sides): the full-discovery model catalog
build behind chat /models dropped from ~6.3s to ~0.3s (~21x), with
repeat snapshot lookups going from full rescans to memo hits.

Regression test: alternating derived call shapes must not re-scan
(red on main: 4 scans; green with this fix: 2).
2026-06-11 20:35:59 +09:00
Yuval Dinodia
83705fba04 fix(config): stop config.patch replacePaths index suffix from widening array consent (#91966)
* fix(config): stop config.patch replacePaths index suffix from widening array consent

normalizeConfigPatchReplacePath stripped a trailing array bracket, so an entry/index-scoped token like bindings[0] or bindings[] collapsed onto the bare whole-array token (bindings). That bare token is both the merge replaceArrayPaths key and the destructive-array gate's exact-path token, so an index-scoped consent silently authorized a full-array replacement and dropped unrelated base entries on the gateway config.patch path, and the same collapse let the agent self-edit tool truncate id-keyed arrays whenever no protected path happened to be involved.

Keep the interior index normalization (agents.list[0].skills -> agents.list[].skills) but no longer collapse a trailing bracket, so a bracket/index-suffixed token never matches the bare whole-array token and the destructive-array gate stays fail-closed unless the documented exact path is passed. Update the agent-tool test whose expectation depended on the old collapse: agents.list[0] now does a non-destructive id-keyed merge that only changes model and is correctly allowed.

* fix(config): distinguish indexed and array replace consent

* test(config): cover replace consent syntax

* fix(config): make replace path normalization idempotent

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 20:35:52 +09:00
Vincent Koc
3883d7365e fix(qqbot): guard silent-final tool flushing 2026-06-11 20:09:55 +09:00
吴杨帆
71d3d8bc74 fix(doctor): warn on unsupported hook entry loaders (#89319)
* fix(doctor): warn on unsupported hook entry loaders

* fix(doctor): guard null hook entry configs

* fix(doctor): guard null hook entry configs

* fix(doctor): repair misplaced hook loader paths

* fix(doctor): clarify unsupported hook entry repair

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 20:09:51 +09:00
Alix-007
0b80732137 fix(cron): reject durations that overflow to a non-finite value (#89448)
* fix(cron): reject durations that overflow to a non-finite value

parseDurationMs guarded the parsed mantissa but returned Math.floor(n * factor)
with no finite check on the product. A finite mantissa times a large unit factor
(e.g. "1e302d", factor 86_400_000) overflows to Infinity, which was returned as
the millisecond value. Reject a non-finite result instead, matching the existing
contract that already rejects non-finite / non-positive mantissas.

Fixes #83906.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: rerun flaky runner checks

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:09:45 +09:00
Syu
038da08073 fix(discord): clean migrated thread binding state (#89552)
* fix(discord): clean migrated thread binding state

* fix(discord): omit undefined thread binding fields

---------

Co-authored-by: Hex <hex@openclaw.ai>
2026-06-11 20:09:39 +09:00
Yuval Dinodia
4b4211e6c7 fix(agents): keep migrated session entry ids unique on v1 upgrade (#89085)
migrateV1ToV2 assigned each entry id via generateId(ids) but never added the
result back into ids, so the collision-check set stayed empty for the whole
migration and generateId's check was a no-op. A v1 to v2 upgrade could then mint
two entries with the same 8-hex id, and because the migration rebuilds the
parent/child tree from those ids it would parent the second entry to itself,
corrupting the branch. Add ids.add(entry.id) so the generator sees prior ids and
retries on collision.

Adds a regression test that drives the real SessionManager.open migration path
with a seeded id collision.
2026-06-11 20:09:33 +09:00
cornna
1532b46e2a fix(models): clarify provider model registration hint (#89508)
Co-authored-by: Cornna <96944678+ymylive@users.noreply.github.com>
2026-06-11 20:09:27 +09:00
Vincent Koc
60e818f563 fix(agents): forward channel identity to CLI hooks 2026-06-11 19:33:00 +09:00
Sliverp
7e88c287a1 fix(qqbot): flush tool output before silent non-streaming final (#92074)
* fix(qqbot): flush tool output before silent non-streaming final

* fix qqbot silent final delivery

* chore: drop local plugin runtime helper

* fix: suppress stale qqbot tool flush

* fix(qqbot): flush tool output before silent non-streaming final (#92074) (thanks @sliverp)

---------

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
2026-06-11 18:28:32 +08:00
Leon GyeongMan Baek
96e1b8c3ba Google: show detailed Gemini CLI OAuth extraction failures (#41991)
* Google: preserve Gemini CLI OAuth failure context

Port the diagnostics-only fix to the bundled Google OAuth implementation so user-facing setup errors explain why automatic Gemini CLI client-config discovery failed.

Constraint: #54289 may remove or gate automatic Gemini CLI credential extraction
Rejected: Change extraction consent behavior here | security/product decision belongs in #54289
Confidence: medium
Scope-risk: narrow
Tested: pnpm test -- extensions/google/oauth.test.ts
Tested: pnpm check
Tested: pnpm format:check extensions/google/oauth.credentials.ts extensions/google/oauth.test.ts
Not-tested: full pnpm test suite

* Google: clarify Gemini bundle fallback diagnostic comment

Keep the follow-up limited to the explanatory comment so it matches the diagnostic error preservation added around bundle traversal failures.

Constraint: comment-only cleanup after diagnostics port
Confidence: high
Scope-risk: narrow
Tested: pnpm format:check extensions/google/oauth.credentials.ts
Not-tested: tests not run; comment-only change

* fix: resolve OAuth test rebase conflict
2026-06-11 19:24:52 +09:00
Lu Wang
43b4e27699 fix(thinking): apply Claude profile to anthropic-messages catalog rows (#92053)
* fix(thinking): apply Claude profile to anthropic-messages catalog rows

When a custom provider (e.g. `jdcloud-anthropic`) fronted Claude Opus over
the native anthropic-messages adapter, `--thinking xhigh` was silently
clamped to `off`. The thinking-profile dispatcher resolves bundled plugin
policy surfaces by exact provider id, so a renamed Anthropic-compatible
provider never reached the anthropic plugin's policy and `xhigh` was not
in the resulting profile.

`auto-reply/thinking.ts` already had a fallback keyed on
`context.api === "anthropic-messages"` that attached
`CLAUDE_FABLE_5_THINKING_PROFILE` for Fable models. Generalize it to use
`resolveClaudeThinkingProfile(modelId, params)` instead — the same
canonical helper the anthropic plugin uses — which still returns the Fable
profile for Fable models and now returns the correct Opus 4.7/4.8 profile
(with `xhigh`/`adaptive`/`max`) for Claude Opus regardless of provider id.

Non-Claude models on anthropic-messages routes still get the base
profile, and a Claude id on a non-Anthropic transport (e.g. an
openai-completions catalog row) is unaffected.

Fixes #91975

* fix(thinking): match native Anthropic includeNativeMax in fallback

Address ClawSweeper P2 review on #92053. The anthropic-messages fallback
in `resolveThinkingProfile` calls `resolveClaudeThinkingProfile` but
omits the `{ includeNativeMax: true }` option that the bundled anthropic
plugin uses (extensions/anthropic/provider-policy-api.ts:38,45).

For native-xhigh Claude families (Opus 4.7/4.8) this had no effect since
the native-xhigh branch already exposes `max`. But adaptive Claude
families that take the adaptive-default branch (e.g. claude-sonnet-4-6,
claude-opus-4-6) silently lost `max` parity on custom anthropic-messages
providers compared to native Anthropic policy.

Also add a regression test on `claude-sonnet-4-6` that verifies the
adaptive-branch path keeps `max` for custom providers.

* docs(thinking): document deliberate compat.xhigh bypass on anthropic-messages

Self-review surfaced a subtle behavior change worth documenting: when the
anthropic-messages fallback was generalized, non-Claude models on this
transport stop honoring catalog `compat.supportedReasoningEfforts: ["xhigh"]`
because they take the Claude base profile instead of falling through to the
later `catalogSupportsXHigh` upgrade path.

This is intentional — anthropic-messages does not carry a generic xhigh
contract; xhigh on this protocol is a Claude-family capability. Add an
inline comment at the resolver site and a regression test that locks the
suppression so the next reader (or a future patch) doesn't accidentally
restore the upgrade path.

* fix(thinking): extract Claude profile to leaf to break import cycle

The previous commits added a `resolveClaudeThinkingProfile` import from
`auto-reply/thinking.ts` to `plugin-sdk/provider-model-shared.ts`. The
shared barrel re-exports `provider-replay-helpers` and `plugins/types`,
which transitively reach back into `auto-reply` via the gateway server
methods chain — creating the madge cycle reported by
`check:madge-import-cycles`:

    auto-reply/thinking.ts
      -> ... -> plugin-sdk/provider-model-shared.ts
      -> plugins/{config-schema, host-hooks, ...} -> plugins/types.ts

Move `BASE_CLAUDE_THINKING_LEVELS`, `isClaudeAdaptiveThinkingDefaultModelId`,
and `resolveClaudeThinkingProfile` to a new leaf module
`src/plugins/provider-claude-thinking.ts` whose only imports are
`@openclaw/llm-core` and the existing leaf `provider-thinking.types`.

`provider-model-shared.ts` continues to re-export both helpers so existing
consumers (`extensions/anthropic/*`, the public test surface) are
unaffected. `auto-reply/thinking.ts` now imports the leaf directly,
breaking the cycle.

* test(thinking): add live proof harness for #91975 anthropic-messages clamp

---------

Co-authored-by: wanglu241 <wanglu241@jd.com>
2026-06-11 19:24:46 +09:00
Lu Wang
43b1088962 fix(cli-runner): scope claude-cli queue to live-session owner identity (#91946) (#91974)
* fix(cli-runner): scope claude-cli queue to live-session owner identity

Fresh claude-cli runs without a stored cliSessionId previously collapsed
onto a single workspace-scoped queue key, serializing all fan-out within
one workspace regardless of subagent lane configuration.

Replace the workspace fallback with the same owner identity that
claude-live-session.ts already uses for its live-session map
(agentAccountId + agentId + authProfileId + sessionId + sessionKey),
keeping per-session resume safety while letting independent OpenClaw
sessions in the same workspace run concurrently.

Refactor buildClaudeLiveKey() to share the new buildClaudeOwnerKey()
helper so the queue key and the live-session key cannot drift.

Refs: #91946

* test(cli-runner): pin owner-key hash + document buildClaudeOwnerKey contract

Add a golden-hash regression test for buildClaudeOwnerKey using the
exact legacy fixture, so a future refactor that reorders fields or
flips the JSON encoding can't silently orphan every deployed Claude
live session at upgrade. Hash verified empirically against the prior
inline sha256(JSON.stringify(...)) in buildClaudeLiveKey.

Add a JSDoc on buildClaudeOwnerKey explaining the cross-module contract
between the CLI run queue and the live-session map.

Refs: #91946

* docs(cli-runner): tighten buildClaudeOwnerKey contract comment

The previous comment claimed an encoding mismatch would orphan deployed
live sessions across upgrades. The Claude live-session registry is
process-local, so any restart already discards every entry — the real
invariant is that the queue path and live-session path produce
byte-identical owner keys *within a single process*, so a fresh queued
turn picks up the same live session the registry already holds. Update
the helper docstring and the golden-hash test description accordingly;
the pinned hash and behavior are unchanged.

* test(cli-runner): add owner-key concurrency demo script

A pure-Node, no-test-runner demo that reproduces the PR-head queue
behavior end-to-end: BEFORE-PR collapse (workspace lane), distinct-owner
overlap, and identical-owner serialization, all in one run with
millisecond-stamped event ordering. Useful as a low-overhead regression
check for the owner-key contract and as a maintainer-runnable proof
artifact for #91946.

* test(cli-runner): satisfy oxlint curly + no-promise-executor-return

Wrap single-statement if/for-of bodies in braces and rewrite the
sleep helper so its Promise executor is a void block instead of an
arrow with an implicit return. No behavior change; demo output and
the byte-equivalent slice fingerprints are unchanged.

---------

Co-authored-by: wanglu241 <wanglu241@jd.com>
2026-06-11 19:24:39 +09:00
Sunjae Kim
5d42ad6654 Stabilize A2A prompt cache metadata (#90173)
A2A session routing identifiers are needed for delivery provenance, but concrete session keys in extraSystemPrompt make the agent system prompt vary between otherwise identical handoffs. Keep the model-facing system context stable by describing high-cardinality session slots with placeholders while retaining concrete values in inputProvenance. Channel names stay concrete: they are low-cardinality (discord/slack/webchat/...), so they do not meaningfully fragment the cache, and they inform reply formatting on the receiving agent.

Constraint: OpenClaw contributor PRs require focused behavior proof and tests for prompt/cache-facing changes.

Rejected: Removing routing metadata entirely | would weaken model context for requester/target roles.

Rejected: Placeholdering channel values too | drops model-visible formatting context for negligible cache benefit (reviewer feedback).

Confidence: medium

Scope-risk: narrow

Directive: Keep concrete session identifiers out of extraSystemPrompt; preserve them in structured provenance or payload fields. Low-cardinality channel labels may stay model-visible.

Tested: node scripts/run-vitest.mjs src/agents/tools/sessions-send-helpers.test.ts src/agents/openclaw-tools.sessions.test.ts

Tested: corepack pnpm exec oxfmt --check src/agents/tools/sessions-send-helpers.ts src/agents/tools/sessions-send-helpers.test.ts src/agents/openclaw-tools.sessions.test.ts

Tested: node scripts/run-oxlint.mjs src/agents/tools/sessions-send-helpers.ts src/agents/tools/sessions-send-helpers.test.ts src/agents/openclaw-tools.sessions.test.ts

Tested: git diff --check

Tested: live before/after provider cache trace (isolated local gateway, two A2A sends from distinct requester sessions; see PR Real behavior proof)

Co-authored-by: Sunjae Kim <sunjaekim@bigvalue.co.kr>
2026-06-11 19:24:32 +09:00
xydt-tanshanshan
865fdab075 fix(memory): preserve live SQLite index during swaps
Fixes #91216.

Preserve the live memory SQLite index during atomic reindex swaps by publishing the POSIX main DB with an overwrite rename, keeping target WAL/SHM sidecars rollbackable until publish succeeds, and refusing no-create post-swap reopens that would otherwise auto-create an empty DB.

Verification:
- node scripts/run-vitest.mjs extensions/memory-core/src/memory/manager.atomic-reindex.test.ts extensions/memory-core/src/memory/manager-db-probe.test.ts extensions/memory-core/src/memory/manager.self-heal-missing-identity.test.ts extensions/memory-core/src/memory/manager-reindex-state.test.ts extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts extensions/memory-core/src/memory/manager.readonly-recovery.test.ts
- git diff --check origin/main...HEAD
- node scripts/run-oxlint.mjs extensions/memory-core/src/memory/manager-atomic-reindex.ts extensions/memory-core/src/memory/manager.atomic-reindex.test.ts extensions/memory-core/src/memory/manager-db.ts extensions/memory-core/src/memory/manager-db-probe.test.ts extensions/memory-core/src/memory/manager-sync-ops.ts
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- GitHub CI on 60df2b4178
2026-06-11 19:15:20 +09:00
openclaw-clownfish[bot]
c692fabeba fix(feishu): reply inside P2P direct-message threads (#92136)
* fix(feishu): keep P2P replies inside direct-message threads

* fix(clownfish): address review for ghcrawl-165996-agentic-merge (1)

* fix(clownfish): address review for ghcrawl-165996-agentic-merge (1)

Co-authored-by: LiaoyuanNing@TTC <259494737+LiaoyuanNing@users.noreply.github.com>

---------

Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: LiaoyuanNing@TTC <259494737+LiaoyuanNing@users.noreply.github.com>
2026-06-11 18:57:51 +09:00
lizeyu-xydt
1e878dde7c #92109: [Bug]: EmbeddedAttemptSessionTakeoverError caused by Btrfs ctimeNs instability (#92123)
* fix(session-lock): remove ctimeNs from session file fingerprint comparison

Btrfs background maintenance (snapshots, scrub, quota) updates ctime
without any file content change. Including ctimeNs in the fingerprint
causes false-positive EmbeddedAttemptSessionTakeoverError on all Btrfs
filesystems, breaking cron jobs and subagent spawns with 100% failure.

dev + ino + size + mtimeNs are sufficient to detect external writes —
any content change will also update mtimeNs and/or size. ctimeNs only
tracks metadata changes and adds no meaningful protection.

Closes #92109

* test(session-lock): cover ctime-only fence drift

* fix(session-lock): narrow ctime drift acceptance

* fix(session-lock): trust verified ctime drift

* fix(session-lock): bound ctime drift digest

* fix(session-lock): skip absent ctime digest

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 18:40:38 +09:00
Vincent Koc
dbf1b742be test(ci): align Anthropic agent expectations 2026-06-11 18:35:11 +09:00
Vincent Koc
7ae90845ab Merge branch 'perf/main-ci-tail' of https://github.com/openclaw/openclaw into perf/main-ci-tail
* 'perf/main-ci-tail' of https://github.com/openclaw/openclaw:
  perf(ci): isolate Docker tooling tests
2026-06-11 18:15:53 +09:00
Vincent Koc
f1401b2cac perf(ci): isolate Docker tooling tests 2026-06-11 18:13:36 +09:00
Vincent Koc
4430f89697 perf(ci): isolate Docker tooling tests 2026-06-11 17:40:04 +09:00
Ayaan Zaidi
0c4fc0a2e3 refactor(agents): derive CLI commentary classification from consumer presence 2026-06-11 13:57:39 +05:30
ragesaq
6396221858 fix(auto-reply): stop dropping claude-cli narration when commentary lane is off
After #91976, the claude-cli JSONL parser reclassifies assistant text that
precedes a tool_use block as commentary. The classification gate
(commentaryProgressEnabled !== undefined) was looser than the delivery gate
(commentaryProgressEnabled === true && onItemEvent), so any channel that
defined the flag as false engaged classification with no consumer wired:
flushPendingClaudeCommentaryText() called an undefined onCommentaryText and
silently discarded the text. On Discord with verbose off this dropped all
inter-tool narration and the pre-final-answer preamble text.

Two-layer fix:
- Align the classify gate with the delivery gate in both CLI dispatch sites
  (agent-runner-execution, followup-runner) so classification only engages
  when a commentary consumer exists.
- Defense in depth: flushPendingClaudeCommentaryText() now falls back to the
  assistant text lane instead of discarding when no consumer is wired, so no
  future gate mismatch can silently eat model output.

Reported on Discord: claude-cli backend lost interleaved narration and the
regular-text reasoning preamble with or without /verbose on.
2026-06-11 13:57:39 +05:30
Peter Steinberger
418d7e1e83 fix(clickclack): allow explicit enable through plugin allowlist (#92084)
Allow an explicit canonical ClickClack enable/setup selection to record ClickClack in a nonempty plugin allowlist, while preserving unrelated allowlist rejection, denylist authority, and global plugin disablement.

Validated at source head 24af9d8e75 with focused regressions, built-CLI disposable-config E2E, security checks, and autoreview. Merged under owner authorization despite the two documented untouched-main agent-core baseline failures.
2026-06-10 21:47:20 -07:00
Marvinthebored
8e81bf774e fix(sessions): preserve user model override across daily/idle rollover (#90128)
User-driven /model (and sessions.patch) overrides were dropped when a
session rolled over at the daily/idle reset boundary, reverting to the
configured default on the next turn despite the 'Model set to ... for
this session' ack. The override-preservation carryover in
initSessionState was gated on resetTriggered, so implicit stale
rollovers (the common case for always-on channel sessions) skipped it.

Run resolveResetPreservedSelection for any rollover that mints a new
session from an existing entry (explicit /new + /reset AND implicit
stale daily/idle). resolveResetPreservedSelection already preserves only
user-driven overrides and clears auto-fallback pins, so resets still
return to the default.

Adds regression tests in session.test.ts covering both cases.

Fixes #90119

Filed with AI assistance (OpenClaw agent); reviewed by @Peetiegonzalez.

Co-authored-by: Marvinthebored <marvinthebored@users.noreply.github.com>
2026-06-10 23:31:49 -04:00
Ayaan Zaidi
bd96e4d22d refactor(auto-reply): distill verbose commentary lane wiring 2026-06-11 08:18:04 +05:30
Ayaan Zaidi
317ba47543 test(discord): add onVerboseProgressVisibility to dispatch params type 2026-06-11 08:18:04 +05:30
Cameron Beeley
b25b8f396c fix(discord): yield the commentary draft while durable verbose progress is active
Discord consumes the dispatch verbose-progress visibility getter the same way
Telegram does: while the durable lane is delivering commentary as standalone
messages, the ephemeral progress draft skips its preamble lines so commentary
renders exactly once. Covered by an active/inactive regression pair.
2026-06-11 08:18:04 +05:30
Cameron Beeley
4ce1d7843a feat(auto-reply): emit durable tool summaries from CLI runner tool results
The CLI parser already emits tool result events (name, toolCallId, isError,
sanitized result), but the runner bridge dropped them, so CLI-backed runs had
no durable tool record under verbose while embedded runs did. The bridge now
forwards result events, and both runners feed a summary tracker that renders
the same formatToolAggregate line the embedded runner emits (meta captured
from the start event args), plus the tool output block when full verbose
output is enabled. Delivery rides each runner's existing tool-result route, so
verbose gating, ordering ahead of the final answer, and the Telegram durable
routing all apply unchanged.
2026-06-11 08:18:04 +05:30
Cameron Beeley
dc55a5b112 feat(telegram): route verbose progress payloads durably instead of into the streaming draft
With streaming on, the dispatcher diverted tool-kind payloads (including the
new durable commentary messages) into the ephemeral progress draft, where they
were discarded when the final answer arrived - so verbose runs lost their
progress record whenever streaming was enabled. While the durable verbose lane
is active (per the dispatch visibility getter), tool payloads are now sent as
real standalone messages and the draft yields its commentary lines; tool/plan
draft lines keep the draft since they have no durable counterpart. Reasoning
lane and tool status reactions are unaffected.
2026-06-11 08:18:04 +05:30
Cameron Beeley
a397fcabd9 feat(auto-reply): deliver inter-tool commentary as standalone verbose progress messages
When verbose progress is enabled, preamble item events now flush as durable
standalone progress messages through the same delivery path as tool summaries,
instead of living only in ephemeral channel streaming drafts. The latest text
per item id is buffered so snapshot-style producers send one message per item;
the buffer flushes when the producer moves on (next item, tool event, block
reply, or final reply) and drains before the final answer.

Verbose runs also force commentary classification on (commentaryProgressEnabled),
so inter-tool text routes to the commentary lane rather than being folded into
the final answer text.

Dispatch additionally exposes a live verbose-progress visibility getter via the
new onVerboseProgressVisibility reply option, so draft-rendering channels can
route progress to the durable lane while it is active.
2026-06-11 08:18:04 +05:30
Shubhankar Tripathy
0bcabea9cc fix(discord): scope command-deploy cache by application id (#77367)
* fix(discord): scope command-deploy cache by application id

Multi-bot Discord setups share a single command-deploy-cache.json under the
state dir. Cache keys were unscoped (`global:reconcile`, `guild:<id>`), so a
later account whose command set hashed identically to an earlier account would
hit the shared hash and skip its own application's command reconcile entirely
— Discord's Integration panel showed 'This application has no commands' for
the secondary bot even though gateway connect, application id, and token were
all valid.

Scope every cache key with `app:<clientId>:` so each Discord application
reconciles independently. Add regression tests covering: two applications with
identical command sets each call REST against their own application; a single
application with the same command set still hits the persisted cache; the
on-disk cache JSON contains application-scoped keys.

Fixes #77359.

* fix(discord): merge on-disk hashes inside persistHashes to survive concurrent writes

Codex follow-up on #77359 noted that server-channels.ts can start multiple
Discord deployers concurrently, so two deployers that both load the cache
file before either persists end up with the second writer overwriting the
first writer's app-scoped key — defeating the rate-limit cache that the
file exists to provide.

Inside persistHashes, re-read the on-disk cache and merge it with our
in-memory entries before the rename. Our in-memory entries always win on
key collisions (we just produced them); on-disk entries we don't have in
memory are preserved. Refresh in-memory state after the write so future
writes from the same deployer also keep entries other deployers added.

This is the lighter of the two repairs the codex review suggested
(re-read/merge vs serialize writes); it covers the realistic case where
one deployer writes before the other persists. Add a regression test that
exercises the load-then-other-deployer-writes-then-persist sequence.

* fix(discord): serialize command-deploy cache persists via in-process mutex

Codex follow-up on #77367 noted: re-read-before-write inside persistHashes
isn't enough — two deployers running persistHashes in true parallel can
both read the same snapshot before either writes, and the later rename
overwrites the earlier writer's app-scoped entries.

Add a module-level Map<storePath, Promise<void>> mutex and wrap the
read-merge-write cycle in withCachePersistLock so concurrent persists for
the same on-disk path serialize. In-process is sufficient because Discord
deployers only run inside the gateway process.

New regression test fires three deployers via Promise.all on the same
tick and asserts all three application-scoped entries survive — pre-fix
this race lost at least one entry.

* fix(discord): add override modifier on StaticCommand.description to satisfy strict TS

Current main enables noImplicitOverride; the StaticCommand test helper
re-declares the concrete BaseCommand.description property, which now
requires an explicit 'override' modifier (TS4114).

* test(discord): suppress typescript/unbound-method on vitest mock refs

The createRest() helper returns vi.fn() handlers cast as RequestClient,
so expect(rest.get).toHaveBeenCalledTimes(...) triggers
typescript/unbound-method 12 times. File-level disable: these are
vitest mock identities, not unbound class methods.

* fix(discord): clean up command cache lock

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-10 22:45:54 -04:00
Vincent Koc
d4fcc38696 fix(anthropic): repair Mythos contract types 2026-06-11 11:15:47 +09:00
Vincent Koc
25ca39e876 fix(tts): preserve async model discovery 2026-06-11 11:11:23 +09:00
Vincent Koc
7acedeaf11 fix(anthropic): require Mythos adaptive thinking 2026-06-11 11:11:23 +09:00
Vincent Koc
4a06e4c773 test(tts): update prepared completion contract 2026-06-11 11:11:23 +09:00
Vincent Koc
33f44d0f27 fix(tts): use prepared completion auth 2026-06-11 11:11:23 +09:00
Vincent Koc
f76bdffd06 fix(bedrock): normalize Mythos minimal effort 2026-06-11 11:11:23 +09:00
Vincent Koc
aa5b1bd697 fix(auth): apply runtime auth to labels 2026-06-11 11:11:22 +09:00
Vincent Koc
abec88cfab fix(auth): apply runtime request overrides everywhere 2026-06-11 11:11:22 +09:00
Vincent Koc
25bea06596 fix(foundry): type runtime auth result 2026-06-11 11:11:22 +09:00
Vincent Koc
08655fb02b fix(foundry): bind auth and thinking contracts 2026-06-11 11:11:22 +09:00
Vincent Koc
76ce9d6d22 fix(foundry): expose Claude 4.6 max effort 2026-06-11 11:11:22 +09:00
Vincent Koc
cc344fee65 fix(foundry): make API labels exhaustive 2026-06-11 11:11:22 +09:00
Vincent Koc
78d621855d fix(foundry): label Anthropic Messages onboarding 2026-06-11 11:11:22 +09:00
Vincent Koc
f0bbe4d95e fix(bedrock): guard Mantle thinking options 2026-06-11 11:11:22 +09:00
Vincent Koc
fdf1ec9f5c fix(bedrock): route Mythos through Mantle 2026-06-11 11:11:22 +09:00
Vincent Koc
d6081f99ff fix(bedrock): keep optional thinking opt-in 2026-06-11 11:11:22 +09:00
Vincent Koc
819fc0037a fix(foundry): align Claude thinking contracts 2026-06-11 11:11:22 +09:00
Vincent Koc
bebe96b402 test(foundry): type auth lookup 2026-06-11 11:11:22 +09:00
Vincent Koc
818c3c276e fix(foundry): preserve canonical thinking identity 2026-06-11 11:11:22 +09:00
Vincent Koc
7d681ab603 fix(vertex): default Mythos to adaptive thinking 2026-06-11 11:11:22 +09:00
Vincent Koc
e3b619505c fix(foundry): use bearer auth in native transport 2026-06-11 11:11:21 +09:00
Vincent Koc
72bfc5e9bf fix(foundry): preserve bearer auth intent 2026-06-11 11:11:21 +09:00
Vincent Koc
6b0f718b9a fix(providers): encode Mythos adaptive requests 2026-06-11 11:11:21 +09:00
Vincent Koc
9b57db3b60 fix(foundry): route bearer auth from headers 2026-06-11 11:11:21 +09:00
Vincent Koc
5e4a84e04f fix(vertex): preserve Mythos thinking policy 2026-06-11 11:11:21 +09:00
Vincent Koc
9ffb5407f7 fix(providers): reconcile Claude profile routing 2026-06-11 11:11:21 +09:00
Vincent Koc
4457ae3e35 fix(providers): preserve Claude output limits 2026-06-11 11:11:21 +09:00
Vincent Koc
a33e58cfbb test(anthropic): type Foundry auth fixtures 2026-06-11 11:11:21 +09:00
Vincent Koc
aabb8498da fix(providers): bound Claude model matching 2026-06-11 11:11:21 +09:00
Vincent Koc
55384bc236 fix(foundry): clamp Mythos Preview thinking 2026-06-11 11:11:21 +09:00
Vincent Koc
e7136005eb fix(types): narrow provider auth metadata 2026-06-11 11:11:21 +09:00
Vincent Koc
9db8601235 fix(foundry): avoid stale setup metadata 2026-06-11 11:11:21 +09:00
Vincent Koc
2031603542 fix(providers): preserve Mythos max routing 2026-06-11 11:11:21 +09:00
Vincent Koc
b4aa520b07 fix(bedrock): preserve Mythos Preview thinking policy 2026-06-11 11:11:21 +09:00
Vincent Koc
4afe616f22 fix(providers): apply auth patch deletions 2026-06-11 11:11:21 +09:00
Vincent Koc
06312ad805 fix(foundry): preserve Sonnet output limits 2026-06-11 11:11:20 +09:00
Vincent Koc
c4493943e1 fix(foundry): clear API key auth on Entra setup 2026-06-11 11:11:20 +09:00
Vincent Koc
15806717d9 fix(foundry): infer selected Claude routes 2026-06-11 11:11:20 +09:00
Vincent Koc
0a8dfc21be fix(foundry): align Claude onboarding contracts 2026-06-11 11:11:20 +09:00
Vincent Koc
378ae44aad fix(foundry): keep API-key Claude auth 2026-06-11 11:11:20 +09:00
Vincent Koc
b66f0b6ca3 fix(foundry): use bearer auth for Claude 2026-06-11 11:11:20 +09:00
Vincent Koc
2fa9a5eaa0 fix(foundry): scope Entra tokens by API 2026-06-11 11:11:20 +09:00
Vincent Koc
3ba617681f fix(providers): preserve adaptive Claude routing 2026-06-11 11:11:20 +09:00
Vincent Koc
7b0b6290b5 test: stabilize full Mac regression suite 2026-06-11 11:10:00 +09:00
Shakker
dac468c731 fix: repair update restart type checks 2026-06-11 02:59:39 +01:00
Vincent Koc
c78d27e4b3 fix(test): bound gateway harness teardown 2026-06-11 10:52:59 +09:00
Vincent Koc
16382e4066 perf(ci): move tooling off artifact critical path 2026-06-11 10:52:59 +09:00
Shakker
36b01d9534 test: update memory helper routing expectation 2026-06-11 02:30:35 +01:00
zengLingbiao
46a3442251 fix(web_fetch): sanitize URL whitespace from LLM tool call arguments (fixes #91651) (#91950)
* fix(web_fetch): sanitize URL whitespace from LLM tool call arguments (fixes #91651)

* fix(web_fetch): narrow URL repair to only strip scheme-authority whitespace

Constrain normalization to the reported malformed-scheme pattern
(whitespace between :// and authority) instead of deleting all
whitespace globally. Path and query spaces are intentionally
preserved — the WHATWG URL parser percent-encodes those correctly.

Add focused regression tests proving path/query/pct-encoded spaces
survive sanitization. (fixes #91651)

* fix(web-fetch): preserve scheme-like path text

* fix(web-fetch): preserve unicode URL text

* fix(web-fetch): retain raw URL argument text

* fix(web-fetch): trim leading unicode URL whitespace

* fix(web-fetch): normalize bare authority whitespace

* fix(web-fetch): satisfy URL sanitizer checks

* fix(web-fetch): pass URL sanitizer lint

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 10:21:07 +09:00
Shakker
400b3e04fb fix: stop unknown-root git services 2026-06-11 02:18:47 +01:00
Shakker
535b1f53ac fix: keep update status polling read only 2026-06-11 02:18:47 +01:00
Shakker
082a4fa6a5 fix: preserve consumed update restart status 2026-06-11 02:18:47 +01:00
Shakker
df52f8ff64 fix: fail stopped service update restart errors 2026-06-11 02:18:47 +01:00
Shakker
2e9c93cb3a fix: isolate update tests from supervisor env 2026-06-11 02:18:47 +01:00
Shakker
80fc9b112b fix: avoid stale cleanup for reachable gateway probes 2026-06-11 02:18:47 +01:00
Shakker
6d32e42a99 fix: continue dev preflight after manager failures 2026-06-11 02:18:47 +01:00
Shakker
9db41b3876 fix: verify managed git restart service 2026-06-11 02:18:47 +01:00
Shakker
dee334f17a fix: constrain dev remote fallback 2026-06-11 02:18:47 +01:00
Shakker
05996096cc fix: clean up failed dev branch fallback 2026-06-11 02:18:47 +01:00
Shakker
54040c6c0e fix: roll back failed dev upstream setup 2026-06-11 02:18:47 +01:00
Shakker
34f2446601 fix: preserve dev branch upstream after fallback 2026-06-11 02:18:47 +01:00
Shakker
4ef71795bc fix: honor selected dev preflight fallback 2026-06-11 02:18:47 +01:00
Shakker
ba2782e64c fix: stop package service during dev switch 2026-06-11 02:18:47 +01:00
Shakker
cf6572dea4 fix: preserve git mode for supervised handoff 2026-06-11 02:18:47 +01:00
Shakker
a7b58f21c5 fix: resolve dev preflight manager from candidate 2026-06-11 02:18:47 +01:00
Shakker
6b3bcc986f fix: require service identity for update handoffs 2026-06-11 02:18:47 +01:00
Shakker
124625ec4d fix: stop git service when switching install roots 2026-06-11 02:18:47 +01:00
Shakker
e44835ae29 fix: resolve dev update preflight from remote main 2026-06-11 02:18:47 +01:00
Shakker
906158f572 fix: extend managed update handoff polling 2026-06-11 02:18:47 +01:00
Shakker
e1c4c3151c fix: keep gateway stopped after plugin update failure 2026-06-11 02:18:47 +01:00
Shakker
6ea8ac7ea3 fix: defer git update service stop until mutation 2026-06-11 02:18:47 +01:00
Shakker
f385aaab8a fix: protect managed git update service ownership 2026-06-11 02:18:47 +01:00
Shakker
3394a4ad2c fix: refresh update status sentinel 2026-06-11 02:18:47 +01:00
Shakker
862790fbd7 fix: verify supervised update handoff in UI 2026-06-11 02:18:47 +01:00
abnershang
a98c158414 docs: align supervised git update handoff docs 2026-06-11 02:18:47 +01:00
abnershang
48ed8f3e81 fix(update): hand off supervised git updates 2026-06-11 02:18:47 +01:00
abnershang
3a2adf856b fix: hand off supervised git updates 2026-06-11 02:18:47 +01:00
Vincent Koc
f03e3372b3 test(memory): type self-heal identity assertions 2026-06-11 10:10:30 +09:00
NVIDIAN
0328ca53cd fix(agents): honor configured CLI resume timeouts (#90912)
* fix(agents): honor configured CLI resume timeouts

* fix(agents): pass explicit CLI timeout overrides

* fix(agents): preserve timeout provenance

* test(agents): cover configured timeout provenance

* fix(agents): resolve CLI timeout provenance centrally

* fix(agents): carry reply timeout provenance

* fix(agents): forward CLI run lane

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 10:05:43 +09:00
Peter Steinberger
88038db3e5 fix(agent): dampen Discord stale thread replies (#91962)
* fix(agent): dampen Discord stale thread replies

* fix(agent): scope Discord quiet prompt by chat type

* fix(agent): forward queued chat type

---------

Co-authored-by: Dallin Romney <dallinromney@gmail.com>
2026-06-10 18:02:58 -07:00
Sergio Cadavid
610691c796 fix(media): resolve state-relative inbound attachments (#92055)
* fix(media): resolve state-relative inbound attachments

* fix(media): preserve cwd attachment precedence

* test(media): cover relative path collisions

* fix(media): skip blocked cwd collisions

* fix(media): validate relative candidates before selection

* fix(media): canonicalize candidate roots

* fix(media): preserve lexical candidate handoff

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 10:00:02 +09:00
Hansraj Singh Thakur
eb673e5c3d fix(cron): structural top-of-hour match in stagger heuristic (#92030)
* fix(cron): structural top-of-hour match in stagger heuristic

Top-of-hour detection used includes('*') on the hour field, accepting
malformed tokens like '5*'. Match only '*' or '*/N' structurally.

* fix(cron): preserve wildcard hour lists

* fix(cron): support question-mark wildcard

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 09:59:57 +09:00
Chunyue Wang
f194be19e2 fix(diagnostics): release wedged session lane when stuck recovery aborts with queued session work (#91802) 2026-06-11 09:59:22 +09:00
xydt-tanshanshan
1a2eb74b9d fix(memory): self-heal missing index identity by initializing provider during sync (#91897)
* [AI] fix(memory): self-heal missing index identity by initializing provider during sync

When a gateway's memory index identity becomes "missing" with chunks
already indexed, canRebuildMissingIdentity stays false because
this.provider is null (async provider init hasn completed yet), so
needsMissingIdentityReindex is false, and the sync loop bails out
with dirty=true forever — the gateway never self-heals.

Now, when indexIdentity.status is "missing" and a provider is
configured but this.provider is null, runSync() calls
ensureProviderInitialized() first, then re-evaluates the identity
state. If the provider becomes available,
canRebuildMissingIdentity flips to true, unlocking the self-heal
reindex path.

Refs #91167

* [AI] fix(memory): allow FTS-only self-heal when chunks are all FTS-only and provider unavailable

When a gateway's memory index identity is 'missing' with chunks already
indexed, canRebuildMissingIdentity stays false if the embedding provider
is unavailable, causing the sync loop to bail out with dirty=true forever.

The previous approach (calling ensureProviderInitialized inside runSync)
was redundant because the public sync() method already initializes the
provider before runSyncWithReadonlyRecovery.

The real fix: when every existing chunk has model='fts-only', rebuilding
the index as FTS-only is safe — no semantic data is lost. So
canRebuildMissingIdentity should also be true when hasOnlyFtsChunks,
even if the provider is unavailable.

Also adds hasSemanticChunks() helper to detect whether any chunks have
a non-fts-only model.

Non-forced test: seeds FTS-only chunks with no meta, syncs without
force, verifies identity transitions from 'missing' to 'valid'.

Refs #91167

* [AI] fix(memory): gate hasSemanticChunks scan to missing-identity path only

Only compute hasOnlyFtsChunks when identity is missing, chunks exist,
and the provider is unavailable. This avoids scanning the chunks table
for model classification on every ordinary sync.

* test(memory): protect semantic index self-heal

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 09:53:01 +09:00
Vincent Koc
adad27d744 fix(exec): honor state dir approvals (#92056) 2026-06-11 09:30:56 +09:00
Vincent Koc
d559dfecfa test(models): complete blank-base-url provider fixture 2026-06-11 08:53:41 +09:00
Alix-007
f5dd33c975 fix(control-ui): make Control UI bootstrap config endpoint base-path-relative (#66946) (#91305)
* fix(control-ui): make bootstrap config endpoint base-path-relative (#66946)

CONTROL_UI_BOOTSTRAP_CONFIG_PATH embedded a hard-coded /__openclaw prefix
instead of being base-path-relative. When the Control UI is served under
/__openclaw__/, both the gateway and the browser loader compose
${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}, producing the doubled
/__openclaw__/__openclaw/control-ui-config.json URL that 404s.

Make the constant base-path-relative (/control-ui-config.json) so the
composed URL is correct under any base path, align the Vite dev stub and
the docs, and add gateway.controlUi.basePath "/__openclaw__" coverage.

* fix(control-ui): serve bootstrap config at default __openclaw__ entry (#66946)

The reporter runs the default deployment (no gateway.controlUi.basePath),
so the Control UI SPA is mounted under the default /__openclaw__/ namespace.
A browser opening that entry infers basePath="/__openclaw__" from the URL
(inferBasePathFromPathname) and fetches /__openclaw__/control-ui-config.json,
but an empty-base-path gateway only served the bare /control-ui-config.json,
so the default-entry bootstrap request 404'd and chat never finished loading.

Make handleControlUiHttpRequest also accept the default-namespace alias
/__openclaw__/control-ui-config.json when no base path is configured. The
alias is derived from the existing CONTROL_UI_NAMESPACE_PREFIX mount constant
and is purely additive: the bare /control-ui-config.json endpoint and the
configured-base-path endpoint are both preserved (no route removed).

Add gateway HTTP coverage for the real default-entry scenario (empty base
path + /__openclaw__/... request) that fails without the alias, alongside the
configured-base-path, bare-path compatibility, and doubled-path 404 cases.

* fix(control-ui): preserve legacy bootstrap endpoint as compat alias (#66946)

Current main and v2026.6.1 serve and document the single-underscore
/__openclaw/control-ui-config.json bootstrap endpoint under an empty
base path (that literal was CONTROL_UI_BOOTSTRAP_CONFIG_PATH before the
path was made base-path-relative). Making the constant relative dropped
that match, so older bundles and clients hitting the documented endpoint
would 404 after upgrading.

Accept the legacy single-underscore path as an empty-base-path
compatibility alias in matchesControlUiBootstrapConfigPath, derived from
the legacy /__openclaw namespace joined with the canonical config
constant (so it tracks any filename rename) and named
LEGACY_BOOTSTRAP_CONFIG_PATH with a comment. The canonical
/control-ui-config.json and the default-namespace
/__openclaw__/control-ui-config.json aliases are unchanged; only this
path is added. The doubled /__openclaw__/__openclaw/... path still 404s.

Add a focused regression that the legacy endpoint returns config under an
empty base path; it 404s without the alias (verified non-vacuous).

* fix(control-ui): preserve legacy bootstrap route under configured base path (#66946)

The previous revision preserved the single-underscore
/__openclaw/control-ui-config.json bootstrap endpoint only under an empty
base path. A deployment with a configured gateway.controlUi.basePath
(e.g. /x) served and documented that endpoint at
${basePath}/__openclaw/control-ui-config.json before this PR made the
config path base-path-relative, so configured-base-path users, older
bundles, and clients that still request it would 404 after upgrading.

Extend matchesControlUiBootstrapConfigPath so the legacy single-underscore
suffix is accepted under every base path, not just the empty one. The
matcher now checks the canonical and legacy suffixes uniformly as
${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH} and
${basePath}${LEGACY_BOOTSTRAP_CONFIG_PATH} for both the empty and
configured cases, reusing the existing LEGACY_BOOTSTRAP_CONFIG_PATH
constant (no new hard-coded literal). The default-namespace
/__openclaw__/control-ui-config.json alias stays empty-base-path-only
(it is the path the inferred default entry requests when no base path is
configured). All three empty-base-path behaviors are unchanged; the
doubled /__openclaw__/__openclaw/... path still 404s under both an empty
and a configured base path.

Add a focused regression that the configured-base-path legacy endpoint
returns the bootstrap config; it 404s without the alias (verified
non-vacuous). No CHANGELOG.md change.

* fix(ui): mount config stub under vite base

* fix(ui): preserve default config stub route

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 08:49:09 +09:00
Vincent Koc
439d2a9404 fix(ui): narrow submitted progress status 2026-06-11 08:43:19 +09:00
Ahmed Tarek
c66fd30595 🐛 fix(openai): remove chatgpt-responses transport override from gpt-5.3-codex catalog entry (#91720)
* fix(openai): remove chatgpt-responses transport override from gpt-5.3-codex catalog entry

The static catalog entry for gpt-5.3-codex hardcoded
api: "openai-chatgpt-responses" and baseUrl: "https://chatgpt.com/backend-api",
forcing all users through the ChatGPT backend — which requires OAuth, not
a standard API key. This broke gpt-5.3-codex for every API-key user after
v2026.6.1.

Remove the transport overrides so the model inherits the provider defaults
(openai-responses + api.openai.com/v1), restoring v2026.5.18 behavior.
OAuth/Codex users are unaffected — dynamic model resolution in
shouldResolveDynamicModelThroughCodex handles the ChatGPT routing based
on provider config, not the static catalog entry.

* ci: retrigger opengrep scan (transient install failure)
2026-06-11 08:42:46 +09:00
Yuval Dinodia
98e239d012 fix(models): keep bundled provider catalog when configured base URL is blank (#91270) (#91292)
* fix(models): keep bundled provider catalog when configured base URL is blank

A models.providers.<id> entry with a blank baseUrl ("") erased the
bundled provider catalog from the generated model registry. The empty
base URL flowed into provider discovery and the catalog merge, where it
overrode the bundled transport URL; the resulting provider then failed
isWritableProviderConfig and was dropped from models.json entirely.

For Google this meant gemini-2.5-flash and gemini-flash-latest stopped
resolving on the embedded runtime with model_not_found, even though the
bundled Google catalog and forward-compat resolver know those ids. This
hit users in merge mode whose config was partially written by a plugin.

Strip blank provider base URLs before discovery and merge so a blank
value means "use the provider default" instead of clobbering it.

Fixes #91270

* test(models): cover blank provider base url

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 08:40:46 +09:00
Ahmed Tarek
69f31ebb0c 🐛 fix(agents): classify harness provider mismatch as format error (#91710) (#91711)
* fix(agents): classify harness provider mismatch as format error (#91710)

When an agent harness rejects a model because the provider id is not in
its supported set, the error message was unclassified — falling through
to reason="unknown" in the model fallback notice. This made harness
provider mismatches (e.g. stale codex plugin rejecting openai/gpt-5.3-codex)
invisible to the user.

Add a format error pattern for the harness rejection message so the
fallback notice reports "format" instead of "unknown".

* ci: retrigger with real behavior proof in PR body
2026-06-11 08:40:41 +09:00
FMLS
84241461fd feat(cron): add readable ISO time fields to cron runs JSON output (#91471)
Adds `tsIso`, `runAtIso`, and `nextRunAtIso` to the JSON output of the `cron runs` command. This enhancement derives local-offset ISO 8601 strings from the existing numeric timestamps purely at the display layer, matching the diagnostic log format. The underlying SQLite storage, protocol schema, and raw numeric fields remain completely unchanged to ensure strict backward compatibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 08:37:28 +09:00
Hansraj Singh Thakur
8ff77c8168 fix(gateway): recover config hot-reload after watcher errors (#92027)
A chokidar watcher 'error' permanently disabled config hot-reload with a
single warn. Re-create the watcher with bounded backoff (500ms/2s/5s, 3
retries); on exhausted budget escalate to log.error and flip a persistent
hotReloadStatus to disabled. stop() clears any pending re-create timer.
2026-06-11 08:36:23 +09:00
Yzx
ecf29d74ec fix(opencode-go): add qwen plus tiered pricing (#91351)
* fix(opencode-go): add qwen plus tiered pricing

* fix(usage): tier pricing by total prompt tokens

* fix(pricing): select qwen tiers by prompt tokens

* fix(pricing): preserve tier basis across catalogs

* fix(opencode-go): keep qwen pricing representable

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 08:36:14 +09:00
Hansraj Singh Thakur
003b3ca6b2 perf(agents): sanitize compaction messages once for token estimation (#92026)
splitMessagesByTokenShare wrapped each message in a 1-element array and
double-cloned it per message. Sanitize the full array once and pass a
precomputed per-message token count array; totals unchanged, allocations
reduced.
2026-06-11 08:30:47 +09:00
Hansraj Singh Thakur
91cc69d70e perf(agents): memoize XML attribute regex in DSML stream parser (#92034)
parseXmlAttribute compiled a fresh RegExp on every call in the DeepSeek DSML
streaming parser. Memoize by attribute name via a module-level Map and escape
the name; behavior unchanged (non-global regex).
2026-06-11 08:30:27 +09:00
Hansraj Singh Thakur
201b5f312f fix(tools): surface unsupported-signal in anyOf availability (#92029)
An anyOf availability group swallowed a nested unsupported-signal authoring
error when any sibling branch parsed as available. Propagate unsupported-signal
diagnostics regardless of sibling availability.
2026-06-11 08:30:04 +09:00
zhang-guiping
feb030f2c6 fix(ui): show prompt progress while sending (#91215)
* fix(ui): show prompt progress while sending

* fix(ui): scope prompt progress to queued send state

* fix(ui): prioritize newer submitted progress

* fix(ui): keep submitted progress authoritative

* fix(ui): preserve current-run terminal status

* test(ui): keep terminal fixture current

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 08:29:44 +09:00
AMARA
f049477dd4 fix(memory): write dream fallback without subagent runtime (#90121) 2026-06-11 08:29:22 +09:00
Ruben Cuevas Menendez
2b89623c50 fix(xai): clarify x_search query guidance (#91163) 2026-06-11 08:29:07 +09:00
Hansraj Singh Thakur
a4a4c76617 fix(sessions): derive channel from direct-chat session keys in send-policy (#92022)
deriveChannelFromKey returned undefined for direct/dm keys, so channel-scoped
send rules never fired for direct chats. Treat parts[0] as the channel for
direct/dm keys too, matching deriveChatTypeFromKey.
2026-06-11 08:28:21 +09:00
Hansraj Singh Thakur
b9e1099f5e fix(gateway): log swallowed background-task finalization errors (#92033)
Background-task finalization swallowed all errors. Log via formatForLog at
warn so non-transient failures are observable, staying non-blocking.
2026-06-11 08:28:03 +09:00
Hansraj Singh Thakur
5d6899c731 fix(mcp): always log channel-bridge notification failures (#92032)
MCP channel-bridge notification failures were only logged when verbose,
otherwise swallowed. Emit one low-noise diagnostic always; gate only the
error detail behind verbose.
2026-06-11 08:27:45 +09:00
zengLingbiao
15498f88fb fix(memory-core): check SQLite plugin state for dreaming ingestion audit after JSON migration (fixes #92017) (#92020)
* fix(memory-core): check SQLite plugin state for dreaming ingestion audit after JSON migration (fixes #92017)

* fix: add SQLite-only regression tests for dreaming ingestion audit (fixes #92017)
2026-06-11 08:26:50 +09:00
Vincent Koc
3659ff8bbf fix(agents): prefer explicit sessions_send keys (#92047)
Honor caller-provided sessionKey values when stale label metadata is also present, and keep denied session-id sends from echoing the resolved canonical session key.

Supersedes openclaw/openclaw#74009 and fixes openclaw/openclaw#64699.

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-11 08:26:25 +09:00
Harjoth Khara
f995f9f411 fix(fal): parse raw completed queue results (#92051) 2026-06-11 08:25:25 +09:00
873 changed files with 46531 additions and 6887 deletions

View File

@@ -317,6 +317,23 @@ pnpm release:check
pnpm test:install:smoke
```
- Before tagging, diff publishable plugin package manifests against the last
reachable stable/beta release tag. For every newly publishable package
(`openclaw.release.publishToNpm: true` or `publishToClawHub: true`) whose
package name did not exist in the base tag, verify the target registry package
already exists in npm/ClawHub or stop and help the owner mint/prepublish the
package first. Do not hide or disable release surfaces just to unblock a
train unless the owner explicitly decides the plugin should not ship in that
release; first-package registry ownership is release prep, not product
rollback. The mint/prepublish path must either be the real release publish
path for the auto-bumped beta version, or a deliberately non-consuming
registry-prep step that cannot occupy the next beta version/tag. Confirm
registry owner, npm scope/package-creation permission, provenance path, and
first-package publish plan before the full release publish continues. Useful
npm probe:
`npm view <package-name> version dist-tags --json --prefer-online`; a 404 for
a package newly added to the release is a release-prep blocker, not something
to discover from the publish job.
- Use `pnpm qa:otel:smoke` when release validation needs telemetry coverage.
It starts a local OTLP/HTTP trace receiver, runs QA-lab's
`otel-trace-smoke`, and checks span names plus content/identifier redaction
@@ -562,7 +579,11 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- Use `NPM_TOKEN` only for explicit npm dist-tag management modes, because npm
does not support trusted publishing for `npm dist-tag add`.
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
- Publishable plugins that are new to npm require owner-led first-package
minting before the full release publish. Do not consume the next beta version
with an ad-hoc manual package publish; use the release-owned auto-bumped
version path, or a non-consuming registry setup/preflight step. Bundled
disk-tree-only plugins stay unpublished.
## Fallback local mac publish
@@ -619,7 +640,9 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
mac app, signing, notarization, and appcast path.
12. Confirm the target npm version is not already published.
13. Create and push the git tag from the release branch.
14. Create or refresh the matching GitHub release.
14. Do not create or publish the matching GitHub release page yet. The real
publish workflow creates or undrafts it only after postpublish verification
and release evidence upload pass.
15. Dispatch Actions > `QA-Lab - All Lanes` against the release tag and wait
for the mock parity, live Matrix, and live Telegram credentialed-channel
lanes to pass.
@@ -642,20 +665,29 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
with `preflight_only=true` and wait for it to pass. Save that run id because
the real publish requires it to reuse the notarized mac artifacts.
21. If any preflight or validation run fails, fix the issue on a new commit,
delete the tag and matching GitHub release, recreate them from the fixed
commit, and rerun all relevant preflights from scratch before continuing.
Never reuse old preflight results after the commit changes. For pushed or
published beta tags, do not delete/recreate; increment to the next beta tag.
For preflight-only failures where npm did not publish the beta version,
delete/recreate the same beta tag and prerelease at the fixed commit instead
of skipping a prerelease number.
delete the tag and any accidental draft/incomplete GitHub release, recreate
the tag from the fixed commit, and rerun all relevant preflights from
scratch before continuing. Never reuse old preflight results after the
commit changes. Once the npm version exists, do not rerun the publish
workflow for that same version; finalize the existing draft/evidence state
manually or cut a correction tag. For pushed or published beta tags, do not
delete/recreate; increment to the next beta tag. For preflight-only failures
where npm did not publish the beta version, delete/recreate the same beta
tag and any accidental draft/incomplete prerelease at the fixed commit
instead of skipping a prerelease number.
22. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with
the same tag for the real publish, choose `npm_dist_tag` (`beta` default,
`latest` only when you intentionally want direct stable publish), keep it
the same as the preflight run, and pass the successful npm
`preflight_run_id`.
23. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
24. Run postpublish verification:
24. Wait for the real publish workflow to run postpublish verification,
create or update the GitHub release as a draft, upload dependency evidence,
append release verification proof, and only then undraft/publish it. If a
waited plugin publish fails after OpenClaw npm succeeds, the workflow keeps
the release draft with OpenClaw npm evidence and exits red; do not undraft
until the plugin publish gap is repaired. The standalone verifier command
remains the recovery probe:
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
25. Run the post-published beta verification roster. First scan current `main`
for critical fixes that landed after the release branch cut; backport only

View File

@@ -0,0 +1,61 @@
name: openclaw-codeql-process-exec-boundary-critical-security
disable-default-queries: true
queries:
- uses: security-extended
query-filters:
- include:
precision:
- high
- very-high
tags contain: security
security-severity: /([7-9]|10)\.(\d)+/
paths:
- src/process
- src/tui/tui-local-shell.ts
- src/tui/tui.ts
- src/plugin-sdk/windows-spawn.ts
- packages/agent-core/src/harness/env
- packages/memory-host-sdk/src/host
- extensions/acpx/src
- extensions/bonjour/src/advertiser.ts
- extensions/browser/src/browser/chrome-mcp.ts
- extensions/browser/src/browser/chrome.executables.ts
- extensions/browser/src/browser/chrome.ts
- extensions/codex/src/app-server/sandbox-exec-server
- extensions/codex/src/app-server/transport-stdio.ts
- extensions/codex/src/node-cli-sessions.ts
- extensions/codex-supervisor/src/json-rpc-client.ts
- extensions/file-transfer/src
- extensions/google-meet/src
- extensions/imessage/src
- extensions/memory-core/src/memory/qmd-manager.ts
- extensions/memory-wiki/src/obsidian.ts
- extensions/microsoft-foundry/cli.ts
- extensions/ollama/src/wsl2-crash-loop-check.ts
- extensions/qa-lab/src
- extensions/signal/src/daemon.ts
- extensions/tts-local-cli/speech-provider.ts
- extensions/voice-call/src
- scripts
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.spec.ts"
- "**/*.spec.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"

View File

@@ -17,7 +17,28 @@ on:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "extensions/acpx/src/**"
- "extensions/bonjour/src/advertiser.ts"
- "extensions/browser/src/browser/chrome-mcp.ts"
- "extensions/browser/src/browser/chrome.executables.ts"
- "extensions/browser/src/browser/chrome.ts"
- "extensions/codex/src/app-server/sandbox-exec-server/**"
- "extensions/codex/src/app-server/transport-stdio.ts"
- "extensions/codex/src/node-cli-sessions.ts"
- "extensions/codex-supervisor/src/json-rpc-client.ts"
- "extensions/file-transfer/src/**"
- "extensions/google-meet/src/**"
- "extensions/imessage/src/**"
- "extensions/memory-core/src/memory/qmd-manager.ts"
- "extensions/memory-wiki/src/obsidian.ts"
- "extensions/microsoft-foundry/cli.ts"
- "extensions/ollama/src/wsl2-crash-loop-check.ts"
- "extensions/qa-lab/src/**"
- "extensions/signal/src/daemon.ts"
- "extensions/tts-local-cli/speech-provider.ts"
- "extensions/voice-call/src/**"
- "packages/**"
- "scripts/**"
- "src/**"
push:
branches:
@@ -26,7 +47,28 @@ on:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "extensions/acpx/src/**"
- "extensions/bonjour/src/advertiser.ts"
- "extensions/browser/src/browser/chrome-mcp.ts"
- "extensions/browser/src/browser/chrome.executables.ts"
- "extensions/browser/src/browser/chrome.ts"
- "extensions/codex/src/app-server/sandbox-exec-server/**"
- "extensions/codex/src/app-server/transport-stdio.ts"
- "extensions/codex/src/node-cli-sessions.ts"
- "extensions/codex-supervisor/src/json-rpc-client.ts"
- "extensions/file-transfer/src/**"
- "extensions/google-meet/src/**"
- "extensions/imessage/src/**"
- "extensions/memory-core/src/memory/qmd-manager.ts"
- "extensions/memory-wiki/src/obsidian.ts"
- "extensions/microsoft-foundry/cli.ts"
- "extensions/ollama/src/wsl2-crash-loop-check.ts"
- "extensions/qa-lab/src/**"
- "extensions/signal/src/daemon.ts"
- "extensions/tts-local-cli/speech-provider.ts"
- "extensions/voice-call/src/**"
- "packages/**"
- "scripts/**"
- "src/**"
schedule:
- cron: "0 6 * * *"
@@ -73,6 +115,11 @@ jobs:
runs_on: blacksmith-4vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
- language: javascript-typescript
category: process-exec-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-process-exec-boundary-critical-security.yml
- language: javascript-typescript
category: plugin-trust-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404

View File

@@ -437,8 +437,17 @@ jobs:
echo "::warning::Could not generate motion-trimmed desktop previews; continuing with screenshots and full MP4 links."
fi
baseline_status="$(jq -r '.scenarios[0].status' "$root/baseline/discord-qa-summary.json")"
candidate_status="$(jq -r '.scenarios[0].status' "$root/candidate/discord-qa-summary.json")"
read_discord_status_reaction_status() {
local lane="$1"
if [[ -f "$root/$lane/qa-evidence.json" ]]; then
jq -r '.entries[0].result.status' "$root/$lane/qa-evidence.json"
return
fi
jq -r '.scenarios[0].status' "$root/$lane/discord-qa-summary.json"
}
baseline_status="$(read_discord_status_reaction_status baseline)"
candidate_status="$(read_discord_status_reaction_status candidate)"
jq -n \
--arg baseline_status "$baseline_status" \

View File

@@ -451,8 +451,17 @@ jobs:
capture_candidate_discord_web
baseline_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/baseline/discord-qa-summary.json")"
candidate_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/candidate/discord-qa-summary.json")"
read_discord_thread_attachment_status() {
local lane="$1"
if [[ -f "$root/$lane/qa-evidence.json" ]]; then
jq -r '.entries[] | select(.test.id == "discord-thread-reply-filepath-attachment") | .result.status' "$root/$lane/qa-evidence.json"
return
fi
jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/$lane/discord-qa-summary.json"
}
baseline_status="$(read_discord_thread_attachment_status baseline)"
candidate_status="$(read_discord_thread_attachment_status candidate)"
comparison_status="fail"
if [[ "$baseline_status" == "fail" && "$candidate_status" == "pass" ]]; then
comparison_status="pass"

View File

@@ -445,8 +445,8 @@ jobs:
telegram_exit=$?
set -e
if [[ ! -f "$root/telegram-qa-summary.json" ]]; then
echo "Telegram live QA did not produce a summary." >&2
if [[ ! -f "$root/qa-evidence.json" && ! -f "$root/telegram-qa-summary.json" ]]; then
echo "Telegram live QA did not produce an evidence summary." >&2
exit "$telegram_exit"
fi
echo "telegram_exit=${telegram_exit}" >> "$GITHUB_OUTPUT"

View File

@@ -1748,6 +1748,7 @@ jobs:
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
minimax) require_any MiniMax MINIMAX_API_KEY ;;
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
openai) require_any OpenAI OPENAI_API_KEY ;;
opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;;
openrouter) require_any OpenRouter OPENROUTER_API_KEY ;;
@@ -1836,7 +1837,7 @@ jobs:
run: |
set -euo pipefail
all_providers=(anthropic google minimax openai opencode-go openrouter xai zai fireworks)
all_providers=(anthropic google minimax moonshot openai opencode-go openrouter xai zai fireworks)
normalize_provider() {
local value="${1,,}"
@@ -1922,6 +1923,7 @@ jobs:
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
minimax) require_any MiniMax MINIMAX_API_KEY ;;
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
openai) require_any OpenAI OPENAI_API_KEY ;;
opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;;
openrouter) require_any OpenRouter OPENROUTER_API_KEY ;;

View File

@@ -527,6 +527,13 @@ jobs:
cleanup_gateway
trap - EXIT
if node -e "const fs=require('node:fs'); const scripts=require('./package.json').scripts||{}; process.exit(scripts['test:sqlite:perf:smoke'] && fs.existsSync('scripts/bench-sqlite-state.ts') ? 0 : 1)"; then
pnpm test:sqlite:perf:smoke
cp .artifacts/sqlite-perf/smoke.json "$SOURCE_PERF_DIR/sqlite-perf-smoke.json"
else
echo "SQLite state smoke probe is not available in ${TESTED_REF}; continuing with the remaining source probes." >> "$GITHUB_STEP_SUMMARY"
fi
summary_args=(node "$PERFORMANCE_HELPER_DIR/scripts/openclaw-performance-source-summary.mjs" \
--source-dir "$SOURCE_PERF_DIR" \
--output "$SOURCE_PERF_DIR/index.md")
@@ -604,7 +611,7 @@ jobs:
## Source probes
Additional gateway boot, memory, plugin pressure, mock hello-loop, and CLI startup numbers are in [source/index.md](source/index.md).
Additional gateway boot, memory, plugin pressure, mock hello-loop, CLI startup, and SQLite state smoke numbers are in [source/index.md](source/index.md).
EOF
fi
fi

View File

@@ -387,7 +387,9 @@ jobs:
run: |
set -euo pipefail
dispatch_workflow() {
dispatch_workflow_at_ref() {
local workflow_ref="$1"
shift
local workflow="$1"
shift
@@ -397,7 +399,7 @@ jobs:
-F per_page=100 \
--jq '[.workflow_runs[].id]')"
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$workflow_ref" "$@" 2>&1)"
printf '%s\n' "$dispatch_output" >&2
run_id="$(
printf '%s\n' "$dispatch_output" |
@@ -432,6 +434,10 @@ jobs:
printf '%s\n' "${run_id}"
}
dispatch_workflow() {
dispatch_workflow_at_ref "$CHILD_WORKFLOW_REF" "$@"
}
print_pending_deployments() {
local workflow="$1"
local run_id="$2"
@@ -653,6 +659,128 @@ jobs:
done
}
guard_existing_public_release() {
local release_version asset_name release_json is_draft has_sha has_proof has_asset release_url
if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then
return 0
fi
if ! release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json isDraft,assets,body,url 2>/dev/null)"; then
return 0
fi
is_draft="$(printf '%s' "${release_json}" | jq -r '.isDraft')"
if [[ "${is_draft}" == "true" ]]; then
return 0
fi
release_version="${RELEASE_TAG#v}"
asset_name="openclaw-${release_version}-dependency-evidence.zip"
has_sha="$(printf '%s' "${release_json}" | jq --arg sha "${TARGET_SHA}" -r '.body | contains($sha)')"
has_proof="$(printf '%s' "${release_json}" | jq -r '.body | contains("### Release verification")')"
has_asset="$(printf '%s' "${release_json}" | jq --arg name "${asset_name}" -r 'any(.assets[]?; .name == $name)')"
release_url="$(printf '%s' "${release_json}" | jq -r '.url')"
if [[ "${has_sha}" == "true" && "${has_proof}" == "true" && "${has_asset}" == "true" ]]; then
return 0
fi
{
echo "Release ${RELEASE_TAG} already has a public GitHub release page without complete postpublish evidence for ${TARGET_SHA}."
echo "Refusing to reuse a public prerelease tag after publication started: ${release_url}"
echo "Create a new beta tag or delete/draft the incomplete public release before retrying."
} >&2
exit 1
}
guard_openclaw_npm_not_already_published() {
local release_version release_url
if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then
return 0
fi
release_version="${RELEASE_TAG#v}"
if ! npm view "openclaw@${release_version}" version >/dev/null 2>&1; then
return 0
fi
release_url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}"
{
echo "openclaw@${release_version} is already published on npm."
echo "Refusing to dispatch publish child workflows for an already-published version."
echo "If this is recovery from a failed postpublish evidence or draft-release step, repair/finalize the existing draft or create a correction tag; do not rerun the publish workflow for the same npm version."
echo "Release page, if present: ${release_url}"
} >&2
exit 1
}
resolve_clawhub_release_plan() {
local -a plan_args
clawhub_plan_path="${RUNNER_TEMP}/openclaw-release-clawhub-plan.json"
plan_args=(
--release-tag "${RELEASE_TAG}"
--release-publish-branch "${CHILD_WORKFLOW_REF}"
--release-publish-run-id "${GITHUB_RUN_ID}"
--plugin-publish-scope "${PLUGIN_PUBLISH_SCOPE}"
)
if [[ -n "${PLUGINS// }" ]]; then
plan_args+=(--plugins "${PLUGINS}")
fi
CLAWHUB_REGISTRY="${CLAWHUB_REGISTRY:-https://clawhub.ai}" \
node --import tsx scripts/openclaw-release-clawhub-plan.ts "${plan_args[@]}" > "${clawhub_plan_path}"
echo "Resolved OpenClaw release ClawHub dispatch plan:"
cat "${clawhub_plan_path}"
clawhub_workflow_ref="$(jq -r '.clawHubWorkflowRef' "${clawhub_plan_path}")"
normal_plugins="$(jq -r '.summary.normalPlugins' "${clawhub_plan_path}")"
bootstrap_plugins="$(jq -r '.summary.bootstrapPlugins' "${clawhub_plan_path}")"
missing_trusted_plugins="$(jq -r '.summary.missingTrustedPlugins' "${clawhub_plan_path}")"
normal_plugin_count="$(jq -r '.summary.normalCount' "${clawhub_plan_path}")"
bootstrap_plugin_count="$(jq -r '.summary.bootstrapCount' "${clawhub_plan_path}")"
missing_trusted_plugin_count="$(jq -r '.summary.missingTrustedPublisherCount' "${clawhub_plan_path}")"
{
echo "### ClawHub release plan"
echo
echo "- Normal OIDC candidates: \`${normal_plugin_count}\`"
echo "- Bootstrap/repair candidates: \`${bootstrap_plugin_count}\`"
echo "- Existing-package trusted-publisher repairs: \`${missing_trusted_plugin_count}\`"
if [[ -n "${normal_plugins}" ]]; then
echo "- Normal plugins: \`${normal_plugins}\`"
fi
if [[ -n "${bootstrap_plugins}" ]]; then
echo "- Bootstrap/repair plugins: \`${bootstrap_plugins}\`"
fi
if [[ -n "${missing_trusted_plugins}" ]]; then
echo "- Trusted-publisher repair plugins: \`${missing_trusted_plugins}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
}
append_clawhub_dispatch_args() {
local target="$1"
while IFS=$'\t' read -r key value; do
clawhub_dispatch_args+=(-f "${key}=${value}")
done < <(jq -r --arg target "${target}" '.[$target].inputs | to_entries[] | [.key, .value] | @tsv' "${clawhub_plan_path}")
}
write_clawhub_runtime_state() {
local force_skip_clawhub="$1"
local output_path="$2"
node --import tsx scripts/openclaw-release-clawhub-runtime-state.ts \
--repository "${GITHUB_REPOSITORY}" \
--wait-for-clawhub "${WAIT_FOR_CLAWHUB}" \
--force-skip-clawhub "${force_skip_clawhub}" \
--normal-run-id "${plugin_clawhub_run_id:-}" \
--bootstrap-run-id "${plugin_clawhub_bootstrap_run_id:-}" \
--bootstrap-completed "${plugin_clawhub_bootstrap_completed:-false}" > "${output_path}"
}
create_or_update_github_release() {
local release_version notes_version title notes_file changelog_file latest_arg prerelease_args
release_version="${RELEASE_TAG#v}"
@@ -698,11 +826,17 @@ jobs:
else
gh release create "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" \
--verify-tag \
--draft \
--title "${title}" \
--notes-file "${notes_file}" \
"${prerelease_args[@]}" \
"${latest_arg}"
fi
echo "- GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
publish_github_release() {
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --draft=false
echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
@@ -735,9 +869,11 @@ jobs:
}
verify_published_release() {
local release_version evidence_path
local release_version evidence_path skip_clawhub clawhub_runtime_state_path
local -a verify_args
skip_clawhub="${1:-false}"
release_version="${RELEASE_TAG#v}"
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
mkdir -p "${POSTPUBLISH_EVIDENCE_DIR}"
@@ -750,16 +886,18 @@ jobs:
--dist-tag "${RELEASE_NPM_DIST_TAG}"
--repo "${GITHUB_REPOSITORY}"
--workflow-ref "${CHILD_WORKFLOW_REF}"
--clawhub-workflow-ref "${clawhub_workflow_ref}"
--full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}"
--plugin-npm-run "${plugin_npm_run_id}"
--openclaw-npm-run "${openclaw_npm_run_id}"
--evidence-out "${evidence_path}"
--skip-github-release
)
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}")
else
verify_args+=(--skip-clawhub)
fi
clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-verify.json"
write_clawhub_runtime_state "${skip_clawhub}" "${clawhub_runtime_state_path}"
while IFS= read -r arg; do
verify_args+=("${arg}")
done < <(jq -r '.verifierArgs[]' "${clawhub_runtime_state_path}")
if [[ -n "${PLUGINS// }" ]]; then
verify_args+=(--plugins "${PLUGINS}")
fi
@@ -775,7 +913,7 @@ jobs:
}
append_release_proof_to_github_release() {
local release_version body_file notes_file tarball integrity telegram_line clawhub_line
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path
release_version="${RELEASE_TAG#v}"
body_file="${RUNNER_TEMP}/release-body.md"
@@ -789,16 +927,16 @@ jobs:
else
telegram_line="- npm Telegram beta E2E: not supplied"
fi
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
clawhub_line="- plugin ClawHub publish: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
else
clawhub_line="- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
fi
clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-proof.json"
write_clawhub_runtime_state false "${clawhub_runtime_state_path}"
clawhub_line="$(jq -r '.proofLines.normal' "${clawhub_runtime_state_path}")"
clawhub_bootstrap_line="$(jq -r '.proofLines.bootstrap' "${clawhub_runtime_state_path}")"
RELEASE_BODY_FILE="${body_file}" \
RELEASE_NOTES_FILE="${notes_file}" \
RELEASE_VERSION="${release_version}" \
RELEASE_TAG="${RELEASE_TAG}" \
RELEASE_SHA="${TARGET_SHA}" \
RELEASE_REPO="${GITHUB_REPOSITORY}" \
RELEASE_TARBALL="${tarball}" \
RELEASE_INTEGRITY="${integrity}" \
@@ -808,6 +946,7 @@ jobs:
PLUGIN_NPM_RUN_ID="${plugin_npm_run_id}" \
OPENCLAW_NPM_RUN_ID="${openclaw_npm_run_id}" \
CLAWHUB_LINE="${clawhub_line}" \
CLAWHUB_BOOTSTRAP_LINE="${clawhub_bootstrap_line}" \
TELEGRAM_LINE="${telegram_line}" \
node --input-type=module <<'NODE'
import { readFileSync, writeFileSync } from "node:fs";
@@ -825,12 +964,14 @@ jobs:
`- npm package: https://www.npmjs.com/package/openclaw/v/${process.env.RELEASE_VERSION}`,
`- registry tarball: ${process.env.RELEASE_TARBALL}`,
`- integrity: \`${process.env.RELEASE_INTEGRITY}\``,
`- release SHA: \`${process.env.RELEASE_SHA}\``,
`- full release CI report: https://github.com/openclaw/releases/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
`- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`,
`- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`,
`- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`,
`- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`,
process.env.CLAWHUB_LINE,
process.env.CLAWHUB_BOOTSTRAP_LINE,
`- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`,
process.env.TELEGRAM_LINE,
].join("\n");
@@ -847,6 +988,7 @@ jobs:
echo "### Publish sequence"
echo
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
echo "- ClawHub workflow ref: release tag \`${RELEASE_TAG}\`"
echo "- Release tag: \`${RELEASE_TAG}\`"
echo "- Release SHA: \`${TARGET_SHA}\`"
echo "- Release approval: this workflow job"
@@ -863,26 +1005,68 @@ jobs:
fi
} >> "$GITHUB_STEP_SUMMARY"
guard_existing_public_release
guard_openclaw_npm_not_already_published
resolve_clawhub_release_plan
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
if [[ -n "${PLUGINS}" ]]; then
npm_args+=(-f plugins="${PLUGINS}")
clawhub_args+=(-f plugins="${PLUGINS}")
fi
plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml "${npm_args[@]}")"
plugin_clawhub_run_id="$(dispatch_workflow plugin-clawhub-release.yml "${clawhub_args[@]}")"
plugin_clawhub_run_id=""
if [[ "$(jq -r '.normal.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then
clawhub_dispatch_args=()
append_clawhub_dispatch_args normal
plugin_clawhub_run_id="$(dispatch_workflow_at_ref \
"$(jq -r '.normal.ref' "${clawhub_plan_path}")" \
"$(jq -r '.normal.workflow' "${clawhub_plan_path}")" \
"${clawhub_dispatch_args[@]}")"
else
echo "- plugin-clawhub-release.yml: no normal OIDC candidates" >> "$GITHUB_STEP_SUMMARY"
fi
plugin_clawhub_bootstrap_run_id=""
plugin_clawhub_bootstrap_completed="false"
if [[ "$(jq -r '.bootstrap.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then
clawhub_dispatch_args=()
append_clawhub_dispatch_args bootstrap
plugin_clawhub_bootstrap_run_id="$(dispatch_workflow_at_ref \
"$(jq -r '.bootstrap.ref' "${clawhub_plan_path}")" \
"$(jq -r '.bootstrap.workflow' "${clawhub_plan_path}")" \
"${clawhub_dispatch_args[@]}")"
else
echo "- plugin-clawhub-new.yml: no bootstrap candidates" >> "$GITHUB_STEP_SUMMARY"
fi
{
echo "- Plugin npm run ID: \`${plugin_npm_run_id}\`"
echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id}\`"
echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id:-none}\`"
echo "- Plugin ClawHub bootstrap run ID: \`${plugin_clawhub_bootstrap_run_id:-none}\`"
} >> "$GITHUB_STEP_SUMMARY"
if ! wait_for_run plugin-npm-release.yml "${plugin_npm_run_id}"; then
echo "Plugin npm publish failed; cancelling ClawHub publish child ${plugin_clawhub_run_id}." >&2
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
echo "Plugin npm publish failed; cancelling dispatched ClawHub child workflows." >&2
if [[ -n "${plugin_clawhub_run_id}" ]]; then
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
fi
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_bootstrap_run_id}" >/dev/null 2>&1 || true
fi
exit 1
fi
if [[ -n "${plugin_clawhub_bootstrap_run_id}" && "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
echo "Waiting for plugin-clawhub-new.yml bootstrap to finish before continuing release publish."
if wait_for_run plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then
plugin_clawhub_bootstrap_completed="true"
else
if [[ -n "${plugin_clawhub_run_id}" ]]; then
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
fi
exit 1
fi
fi
openclaw_npm_run_id=""
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
openclaw_npm_run_id="$(dispatch_workflow openclaw-npm-release.yml \
@@ -899,19 +1083,52 @@ jobs:
clawhub_result=""
clawhub_pid=""
clawhub_bootstrap_result=""
clawhub_bootstrap_pid=""
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
wait_run_pid=""
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
clawhub_pid="${wait_run_pid}"
else
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
:
else
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
if [[ -n "${plugin_clawhub_run_id}" ]]; then
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
wait_run_pid=""
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
clawhub_pid="${wait_run_pid}"
fi
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then
echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY"
else
clawhub_bootstrap_result="$RUNNER_TEMP/clawhub-bootstrap-result.txt"
wait_run_pid=""
wait_for_run_background plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "${clawhub_bootstrap_result}"
clawhub_bootstrap_pid="${wait_run_pid}"
fi
fi
else
if [[ -n "${plugin_clawhub_run_id}" ]]; then
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
:
else
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
else
echo "- plugin-clawhub-release.yml: no normal OIDC publish to await" >> "$GITHUB_STEP_SUMMARY"
fi
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then
echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY"
else
wait_for_job_success plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "Validate release publish approval"
if approve_child_publish_environment plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then
:
else
echo "- plugin-clawhub-new.yml: child environment gate not ready; bootstrap was left dispatched (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
echo "- plugin-clawhub-new.yml: bootstrap not awaited (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
else
echo "- plugin-clawhub-new.yml: no bootstrap publish to await" >> "$GITHUB_STEP_SUMMARY"
fi
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
openclaw_result=""
@@ -934,21 +1151,33 @@ jobs:
openclaw_failed=1
fi
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
create_or_update_github_release
upload_dependency_evidence_release_asset
fi
if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"; then
failed=1
fi
if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then
failed=1
fi
if [[ -n "${clawhub_bootstrap_pid}" ]] && ! wait "${clawhub_bootstrap_pid}"; then
failed=1
fi
if [[ -f "${clawhub_bootstrap_result}" && "$(cat "${clawhub_bootstrap_result}")" != "success" ]]; then
failed=1
fi
if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then
verify_published_release
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
if [[ "${failed}" == "0" ]]; then
verify_published_release
else
verify_published_release true
fi
create_or_update_github_release
upload_dependency_evidence_release_asset
append_release_proof_to_github_release
if [[ "${failed}" == "0" ]]; then
publish_github_release
else
echo "- GitHub release: left as draft because a required publish child failed" >> "$GITHUB_STEP_SUMMARY"
fi
fi
if [[ "${failed}" != "0" ]]; then
exit 1

504
.github/workflows/plugin-clawhub-new.yml vendored Normal file
View File

@@ -0,0 +1,504 @@
name: Plugin ClawHub New
on:
workflow_dispatch:
inputs:
plugins:
description: Comma-separated plugin package names to bootstrap on ClawHub
required: true
type: string
ref:
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
required: false
default: ""
type: string
release_publish_run_id:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
release_publish_branch:
description: Branch name of the approving OpenClaw Release Publish workflow run
required: false
type: string
dry_run:
description: Validate the token-gated ClawHub bootstrap handoff without publishing.
required: false
default: false
type: boolean
concurrency:
group: plugin-clawhub-new-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.15.0"
CLAWHUB_REGISTRY: "https://clawhub.ai"
CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"
jobs:
resolve_bootstrap_plan:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
ref_revision: ${{ steps.ref.outputs.sha }}
has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }}
bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }}
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.ref }}
fetch-depth: 0
- name: Resolve checked-out ref
id: ref
env:
TARGET_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
run: |
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
if [[ -n "${TARGET_REF}" ]]; then
if git rev-parse --verify --quiet "${TARGET_REF}^{commit}" >/dev/null; then
target_sha="$(git rev-parse "${TARGET_REF}^{commit}")"
elif git rev-parse --verify --quiet "origin/${TARGET_REF}^{commit}" >/dev/null; then
target_sha="$(git rev-parse "origin/${TARGET_REF}^{commit}")"
else
echo "Unable to resolve requested publish ref: ${TARGET_REF}" >&2
exit 1
fi
git checkout --detach "${target_sha}"
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate ref is on a trusted publish branch
env:
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if git merge-base --is-ancestor HEAD origin/main; then
exit 0
fi
while IFS= read -r release_ref; do
if git merge-base --is-ancestor HEAD "${release_ref}"; then
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
alpha_branch="${TRUSTED_PUBLISH_BRANCH}"
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
exit 0
fi
fi
echo "Plugin ClawHub bootstraps must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "false"
- name: Validate publishable plugin metadata
env:
RELEASE_PLUGINS: ${{ inputs.plugins }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PLUGINS// }" ]]; then
echo "Plugin ClawHub bootstrap requires at least one package name in plugins." >&2
exit 1
fi
pnpm release:plugins:clawhub:check -- --selection-mode selected --plugins "${RELEASE_PLUGINS}"
- name: Resolve plugin bootstrap plan
id: plan
env:
RELEASE_PLUGINS: ${{ inputs.plugins }}
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: |
set -euo pipefail
mkdir -p .local
node --import tsx scripts/plugin-clawhub-release-plan.ts \
--selection-mode selected \
--plugins "${RELEASE_PLUGINS}" > .local/plugin-clawhub-release-plan.json
cat .local/plugin-clawhub-release-plan.json
bootstrap_candidate_count="$(jq -r '(.bootstrapCandidates | length) + (.missingTrustedPublisher | length)' .local/plugin-clawhub-release-plan.json)"
selected_count="$(jq -r '.all | length' .local/plugin-clawhub-release-plan.json)"
matrix_json="$(
jq -c '
[
.bootstrapCandidates[]? + {
bootstrapMode: "publish",
requiresManualOverride: false
},
.missingTrustedPublisher[]? + {
bootstrapMode: (if .alreadyPublished then "configure-only" else "publish" end),
requiresManualOverride: true
}
]
' .local/plugin-clawhub-release-plan.json
)"
has_bootstrap_candidates="false"
if [[ "${bootstrap_candidate_count}" != "0" ]]; then
has_bootstrap_candidates="true"
fi
invalid_scope="$(
jq -r '
(.bootstrapCandidates[]?, .missingTrustedPublisher[]?)
| select(.packageName | startswith("@openclaw/") | not)
| "- \(.packageName)@\(.version)"
' .local/plugin-clawhub-release-plan.json
)"
if [[ -n "${invalid_scope}" ]]; then
echo "Plugin ClawHub bootstrap only supports @openclaw/* packages." >&2
printf '%s\n' "${invalid_scope}" >&2
exit 1
fi
not_bootstrap="$(
jq -r '
(.bootstrapCandidates | map(.packageName)) as $bootstrapNames
| (.missingTrustedPublisher | map(.packageName)) as $repairNames
| .all[]?
| select(.packageName as $name | ($bootstrapNames + $repairNames | index($name) | not))
| "- \(.packageName)@\(.version)"
' .local/plugin-clawhub-release-plan.json
)"
if [[ -n "${not_bootstrap}" ]]; then
echo "Selected packages must all be first-publish bootstrap candidates or trusted-publisher repair candidates." >&2
printf '%s\n' "${not_bootstrap}" >&2
exit 1
fi
if [[ "${selected_count}" == "0" || "${bootstrap_candidate_count}" == "0" ]]; then
echo "No selected packages require ClawHub bootstrap." >&2
exit 1
fi
{
echo "bootstrap_candidate_count=${bootstrap_candidate_count}"
echo "has_bootstrap_candidates=${has_bootstrap_candidates}"
echo "matrix=${matrix_json}"
} >> "$GITHUB_OUTPUT"
echo "ClawHub bootstrap candidates:"
jq -r '
.bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"
' .local/plugin-clawhub-release-plan.json
echo "ClawHub trusted-publisher repair candidates:"
jq -r '
.missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir), alreadyPublished=\(.alreadyPublished)"
' .local/plugin-clawhub-release-plan.json
- name: Validate Tideclaw alpha plugin channels
env:
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
exit 0
fi
invalid="$(
jq -r '
(.bootstrapCandidates[]?, .missingTrustedPublisher[]?)
| select(.publishTag != "alpha" or .channel != "alpha")
| "- \(.packageName)@\(.version) [\(.publishTag)]"
' .local/plugin-clawhub-release-plan.json
)"
if [[ -n "${invalid}" ]]; then
echo "Tideclaw alpha ClawHub bootstraps may only publish alpha plugin versions." >&2
printf '%s\n' "${invalid}" >&2
exit 1
fi
validate_release_publish_approval:
name: Validate release publish approval
needs: resolve_bootstrap_plan
if: github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
echo "Plugin ClawHub bootstrap dispatched by another workflow must include release_publish_run_id." >&2
exit 1
fi
echo "Direct Plugin ClawHub New dispatch; relying on this workflow's clawhub-plugin-bootstrap environment approval."
exit 0
fi
direct_recovery=false
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
direct_recovery=true
echo "Direct Plugin ClawHub New recovery with release_publish_run_id; relying on this workflow's clawhub-plugin-bootstrap environment approval."
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
validate_bootstrap_trusted_publisher_cli:
needs: [resolve_bootstrap_plan, validate_release_publish_approval]
if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate pinned ClawHub trusted publisher CLI support
env:
CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }}
run: |
set -euo pipefail
help_output="$(
npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \
clawhub package trusted-publisher set --help 2>&1 || true
)"
printf '%s\n' "${help_output}"
if ! grep -Fq "Usage: clawhub package trusted-publisher set" <<<"${help_output}"; then
echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} to expose 'package trusted-publisher set' before token bootstrap publish can run. The pinned CLI returned parent help or no set command, so this workflow is stopping before creating a ClawHub package row."
exit 1
fi
for required_flag in --repository --workflow-filename; do
if ! grep -Fq -- "${required_flag}" <<<"${help_output}"; then
echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} trusted-publisher set help to include ${required_flag}."
exit 1
fi
done
publish_bootstrap_plugins:
needs:
[
resolve_bootstrap_plan,
validate_release_publish_approval,
validate_bootstrap_trusted_publisher_cli,
]
if: always() && github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success' && (inputs.dry_run == true || needs.validate_bootstrap_trusted_publisher_cli.result == 'success')
runs-on: ubuntu-latest
environment: clawhub-plugin-bootstrap
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 8
matrix:
plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.ref }}
fetch-depth: 0
- name: Checkout target revision
env:
TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }}
run: |
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
git checkout --detach "${TARGET_SHA}"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
install-deps: "true"
- name: Verify package-local runtime build
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
- name: Install pinned ClawHub CLI wrapper
run: |
set -euo pipefail
cat > "${RUNNER_TEMP}/clawhub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@"
EOF
chmod +x "${RUNNER_TEMP}/clawhub"
echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}"
- name: Write ClawHub token config
if: inputs.dry_run != true
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
run: |
set -euo pipefail
config_path="${RUNNER_TEMP}/clawhub-config.json"
CONFIG_PATH="${config_path}" node --input-type=module <<'NODE'
import { writeFileSync } from "node:fs";
const registry = process.env.CLAWHUB_REGISTRY?.trim();
const token = process.env.CLAWHUB_TOKEN?.trim();
const configPath = process.env.CONFIG_PATH;
if (!registry) {
throw new Error("CLAWHUB_REGISTRY is required for token-gated ClawHub bootstrap.");
}
if (!token) {
throw new Error("CLAWHUB_TOKEN is required for token-gated ClawHub bootstrap.");
}
if (!configPath) {
throw new Error("CONFIG_PATH is required.");
}
writeFileSync(configPath, `${JSON.stringify({ registry, token }, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
NODE
echo "CLAWHUB_CONFIG_PATH=${config_path}" >> "${GITHUB_ENV}"
- name: Publish ClawHub bootstrap package
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }}
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
BOOTSTRAP_MODE: ${{ matrix.plugin.bootstrapMode }}
REQUIRES_MANUAL_OVERRIDE: ${{ matrix.plugin.requiresManualOverride && 'true' || 'false' }}
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0"
run: |
set -euo pipefail
if [[ "${BOOTSTRAP_MODE}" == "configure-only" ]]; then
echo "Skipping bootstrap publish because ${PACKAGE_DIR} version is already present on ClawHub; configuring trusted publisher only."
elif [[ "${DRY_RUN}" == "true" ]]; then
bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
else
if [[ "${REQUIRES_MANUAL_OVERRIDE}" == "true" ]]; then
export OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON="GitHub Actions trusted publisher repair before OIDC migration"
fi
bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
fi
- name: Configure trusted publisher for normal OIDC releases
if: inputs.dry_run != true
env:
CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }}
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
run: |
set -euo pipefail
npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \
clawhub package trusted-publisher set "${PACKAGE_NAME}" \
--repository openclaw/openclaw \
--workflow-filename plugin-clawhub-release.yml
verify_bootstrap_clawhub_package:
needs: [resolve_bootstrap_plan, publish_bootstrap_plugins]
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 8
matrix:
plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }}
steps:
- name: Verify bootstrap ClawHub package and trusted publisher
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
run: |
set -euo pipefail
node --input-type=module <<'EOF'
const registry = (process.env.CLAWHUB_REGISTRY ?? "https://clawhub.ai").replace(/\/+$/, "");
const packageName = process.env.PACKAGE_NAME;
const packageVersion = process.env.PACKAGE_VERSION;
const packageTag = process.env.PACKAGE_TAG;
if (!packageName || !packageVersion || !packageTag) {
throw new Error("Missing ClawHub bootstrap verification env.");
}
const encodedName = encodeURIComponent(packageName);
const encodedVersion = encodeURIComponent(packageVersion);
const detailUrl = `${registry}/api/v1/packages/${encodedName}`;
const trustedPublisherUrl = `${detailUrl}/trusted-publisher`;
const versionUrl = `${detailUrl}/versions/${encodedVersion}`;
const artifactUrl = `${versionUrl}/artifact/download`;
async function fetchWithRetry(url, options = {}) {
let lastStatus = "unknown";
for (let attempt = 1; attempt <= 12; attempt += 1) {
try {
const response = await fetch(url, { redirect: "manual", ...options });
lastStatus = response.status;
if (response.status !== 429 && response.status < 500) {
return response;
}
} catch (error) {
lastStatus = error instanceof Error ? error.message : String(error);
}
await new Promise((resolve) => setTimeout(resolve, attempt * 5000));
}
throw new Error(`${url} did not stabilize; last status ${lastStatus}.`);
}
const detailResponse = await fetchWithRetry(detailUrl, {
headers: { accept: "application/json" },
});
if (!detailResponse.ok) {
throw new Error(`${detailUrl} returned HTTP ${detailResponse.status}.`);
}
const detail = await detailResponse.json();
const tags = detail?.package?.tags ?? {};
if (tags[packageTag] !== packageVersion) {
throw new Error(
`${packageName}: ClawHub tag ${packageTag} points to ${tags[packageTag] ?? "<missing>"}, expected ${packageVersion}.`,
);
}
const trustedPublisherResponse = await fetchWithRetry(trustedPublisherUrl, {
headers: { accept: "application/json" },
});
if (!trustedPublisherResponse.ok) {
throw new Error(`${trustedPublisherUrl} returned HTTP ${trustedPublisherResponse.status}.`);
}
const trustedPublisherDetail = await trustedPublisherResponse.json();
const trustedPublisher = trustedPublisherDetail?.trustedPublisher;
if (
trustedPublisher?.repository !== "openclaw/openclaw" ||
trustedPublisher?.workflowFilename !== "plugin-clawhub-release.yml" ||
trustedPublisher?.environment != null
) {
throw new Error(
`${packageName}: trusted publisher config did not match openclaw/openclaw plugin-clawhub-release.yml without an environment pin.`,
);
}
const versionResponse = await fetchWithRetry(versionUrl);
if (!versionResponse.ok) {
throw new Error(`${versionUrl} returned HTTP ${versionResponse.status}.`);
}
const artifactResponse = await fetchWithRetry(artifactUrl, { method: "HEAD" });
if (artifactResponse.status < 200 || artifactResponse.status >= 400) {
throw new Error(`${artifactUrl} returned HTTP ${artifactResponse.status}.`);
}
console.log(`${packageName}@${packageVersion} bootstrap verified on ClawHub.`);
EOF

View File

@@ -16,7 +16,7 @@ on:
required: false
type: string
ref:
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
description: Dry-run target ref to validate; real OIDC publishes must dispatch the workflow with --ref set to the target release tag/ref
required: false
default: ""
type: string
@@ -24,6 +24,10 @@ on:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
release_publish_branch:
description: Branch name of the approving OpenClaw Release Publish workflow run
required: false
type: string
dry_run:
description: Validate the full ClawHub artifact handoff without publishing.
required: false
@@ -38,9 +42,7 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.15.0"
CLAWHUB_REGISTRY: "https://clawhub.ai"
CLAWHUB_REPOSITORY: "openclaw/clawhub"
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854"
CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"
jobs:
preview_plugins_clawhub:
@@ -50,9 +52,15 @@ jobs:
outputs:
ref_revision: ${{ steps.ref.outputs.sha }}
has_candidates: ${{ steps.plan.outputs.has_candidates }}
has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }}
has_missing_trusted_publisher: ${{ steps.plan.outputs.has_missing_trusted_publisher }}
candidate_count: ${{ steps.plan.outputs.candidate_count }}
bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }}
missing_trusted_publisher_count: ${{ steps.plan.outputs.missing_trusted_publisher_count }}
skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }}
matrix: ${{ steps.plan.outputs.matrix }}
bootstrap_matrix: ${{ steps.plan.outputs.bootstrap_matrix }}
missing_trusted_publisher_matrix: ${{ steps.plan.outputs.missing_trusted_publisher_matrix }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -83,9 +91,27 @@ jobs:
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate OIDC source matches workflow ref
env:
TARGET_SHA: ${{ steps.ref.outputs.sha }}
WORKFLOW_SHA: ${{ github.sha }}
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
run: |
set -euo pipefail
if [[ "${TARGET_SHA}" != "${WORKFLOW_SHA}" ]]; then
if [[ "${DRY_RUN}" == "true" ]]; then
echo "Dry-run publish target differs from workflow ref; allowing validation-only dispatch."
exit 0
fi
echo "Plugin ClawHub OIDC publishes must run from the same ref that is being published." >&2
echo "The ref input is only supported for dry_run=true." >&2
echo "For real publishes, dispatch this workflow with --ref pointing at the target release tag/ref and omit the ref input." >&2
exit 1
fi
- name: Validate ref is on a trusted publish branch
env:
WORKFLOW_REF: ${{ github.ref }}
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if git merge-base --is-ancestor HEAD origin/main; then
@@ -96,8 +122,8 @@ jobs:
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
alpha_branch="${WORKFLOW_REF#refs/heads/}"
if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
alpha_branch="${TRUSTED_PUBLISH_BRANCH}"
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
exit 0
@@ -158,36 +184,78 @@ jobs:
cat .local/plugin-clawhub-release-plan.json
candidate_count="$(jq -r '.candidates | length' .local/plugin-clawhub-release-plan.json)"
bootstrap_candidate_count="$(jq -r '.bootstrapCandidates | length' .local/plugin-clawhub-release-plan.json)"
missing_trusted_publisher_count="$(jq -r '.missingTrustedPublisher | length' .local/plugin-clawhub-release-plan.json)"
skipped_published_count="$(jq -r '.skippedPublished | length' .local/plugin-clawhub-release-plan.json)"
has_candidates="false"
if [[ "${candidate_count}" != "0" ]]; then
has_candidates="true"
fi
has_bootstrap_candidates="false"
if [[ "${bootstrap_candidate_count}" != "0" ]]; then
has_bootstrap_candidates="true"
fi
has_missing_trusted_publisher="false"
if [[ "${missing_trusted_publisher_count}" != "0" ]]; then
has_missing_trusted_publisher="true"
fi
matrix_json="$(jq -c '.candidates' .local/plugin-clawhub-release-plan.json)"
bootstrap_matrix_json="$(jq -c '.bootstrapCandidates' .local/plugin-clawhub-release-plan.json)"
missing_trusted_publisher_matrix_json="$(jq -c '.missingTrustedPublisher' .local/plugin-clawhub-release-plan.json)"
{
echo "candidate_count=${candidate_count}"
echo "bootstrap_candidate_count=${bootstrap_candidate_count}"
echo "missing_trusted_publisher_count=${missing_trusted_publisher_count}"
echo "skipped_published_count=${skipped_published_count}"
echo "has_candidates=${has_candidates}"
echo "has_bootstrap_candidates=${has_bootstrap_candidates}"
echo "has_missing_trusted_publisher=${has_missing_trusted_publisher}"
echo "matrix=${matrix_json}"
echo "bootstrap_matrix=${bootstrap_matrix_json}"
echo "missing_trusted_publisher_matrix=${missing_trusted_publisher_matrix_json}"
} >> "$GITHUB_OUTPUT"
echo "Plugin release candidates:"
jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
echo "Bootstrap candidates requiring token bootstrap:"
jq -r '.bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
echo "Missing trusted publisher candidates:"
jq -r '.missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
echo "Already published / skipped:"
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-clawhub-release-plan.json
- name: Fail when trusted publisher is missing
if: steps.plan.outputs.missing_trusted_publisher_count != '0'
run: |
echo "::error::One or more ClawHub packages exist but do not have trusted publishing configured. Configure trusted publishing before running the normal OIDC publish workflow."
jq -r '.missingTrustedPublisher[]? | "::error::Missing trusted publisher: \(.packageName)@\(.version). Configure trusted publishing for openclaw/openclaw, workflow plugin-clawhub-release.yml."' .local/plugin-clawhub-release-plan.json
exit 1
- name: Fail normal publish when bootstrap is required
if: steps.plan.outputs.bootstrap_candidate_count != '0'
run: |
echo "::error::One or more ClawHub packages do not exist yet and require the token-gated Plugin ClawHub New bootstrap workflow before normal OIDC publish can run."
jq -r '.bootstrapCandidates[]? | "::error::Bootstrap required: \(.packageName)@\(.version). Dispatch plugin-clawhub-new.yml for this package, then rerun the normal release."' .local/plugin-clawhub-release-plan.json
exit 1
- name: Fail manual publish when target versions already exist
if: github.event_name == 'workflow_dispatch' && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
run: |
echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish."
exit 1
- name: Validate Tideclaw alpha plugin channels
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
env:
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
exit 0
fi
invalid="$(
jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-clawhub-release-plan.json
)"
@@ -197,12 +265,6 @@ jobs:
exit 1
fi
- name: Verify OpenClaw ClawHub package ownership
if: steps.plan.outputs.has_candidates == 'true'
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: node --import tsx scripts/plugin-clawhub-owner-preflight.ts .local/plugin-clawhub-release-plan.json
validate_release_publish_approval:
name: Validate release publish approval
needs: preview_plugins_clawhub
@@ -221,7 +283,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
@@ -240,99 +302,8 @@ jobs:
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
preview_plugin_pack:
needs: preview_plugins_clawhub
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 12
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.ref }}
fetch-depth: 0
- name: Checkout target revision
env:
TARGET_SHA: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
run: |
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
git checkout --detach "${TARGET_SHA}"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
install-deps: "true"
- name: Checkout ClawHub CLI source
uses: actions/checkout@v6
with:
persist-credentials: false
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: main
path: clawhub-source
fetch-depth: 0
- name: Checkout pinned ClawHub CLI revision
working-directory: clawhub-source
env:
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
run: git checkout --detach "${CLAWHUB_REF}"
- name: Install ClawHub CLI dependencies
working-directory: clawhub-source
run: |
set -euo pipefail
for attempt in 1 2 3; do
if bun install --frozen-lockfile; then
exit 0
fi
status="$?"
if [[ "${attempt}" == "3" ]]; then
exit "${status}"
fi
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
sleep $((attempt * 15))
done
- name: Bootstrap ClawHub CLI
run: |
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
EOF
chmod +x "$RUNNER_TEMP/clawhub"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
- name: Verify package-local runtime build
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
- name: Preview publish command
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
pack_plugins_clawhub_artifacts:
needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval]
needs: [preview_plugins_clawhub, validate_release_publish_approval]
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
@@ -367,47 +338,19 @@ jobs:
install-bun: "true"
install-deps: "true"
- name: Checkout ClawHub CLI source
uses: actions/checkout@v6
with:
persist-credentials: false
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: main
path: clawhub-source
fetch-depth: 0
- name: Verify package-local runtime build
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
- name: Checkout pinned ClawHub CLI revision
working-directory: clawhub-source
env:
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
run: git checkout --detach "${CLAWHUB_REF}"
- name: Install ClawHub CLI dependencies
working-directory: clawhub-source
- name: Install pinned ClawHub CLI wrapper
run: |
set -euo pipefail
for attempt in 1 2 3; do
if bun install --frozen-lockfile; then
exit 0
fi
status="$?"
if [[ "${attempt}" == "3" ]]; then
exit "${status}"
fi
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
sleep $((attempt * 15))
done
- name: Bootstrap ClawHub CLI
run: |
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
cat > "${RUNNER_TEMP}/clawhub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@"
EOF
chmod +x "$RUNNER_TEMP/clawhub"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
chmod +x "${RUNNER_TEMP}/clawhub"
echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}"
- name: Pack ClawHub package artifact
env:
@@ -428,19 +371,23 @@ jobs:
if-no-files-found: error
retention-days: 7
approve_plugin_clawhub_release:
approve_plugins_clawhub_release:
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts]
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success'
runs-on: ubuntu-latest
environment: clawhub-plugin-release
permissions: {}
permissions:
contents: read
steps:
- name: Approve ClawHub package publish
run: echo "ClawHub package publish approved."
- name: Approve Plugin ClawHub release publish
run: |
echo "Approved CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows release publish gate."
publish_plugins_clawhub:
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugin_clawhub_release]
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugin_clawhub_release.result == 'success')
needs:
[preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugins_clawhub_release]
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugins_clawhub_release.result == 'success')
uses: openclaw/clawhub/.github/workflows/package-publish.yml@9d49df109d4ad3dc8a6ecf05d26b39f46d294721
permissions:
actions: read
contents: read
@@ -450,19 +397,18 @@ jobs:
max-parallel: 32
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854
with:
dry_run: ${{ inputs.dry_run }}
json: true
package_artifact_name: ${{ matrix.plugin.artifactName }}
dry_run: ${{ inputs.dry_run }}
registry: https://clawhub.ai
site: https://clawhub.ai
tags: ${{ matrix.plugin.publishTag }}
source_repo: ${{ github.repository }}
source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
source_ref: ${{ github.ref }}
tags: ${{ matrix.plugin.publishTag }}
secrets:
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
source_path: ${{ matrix.plugin.packageDir }}
inspector_artifact_name: ${{ matrix.plugin.artifactName }}-inspector
publish_json_artifact_name: ${{ matrix.plugin.artifactName }}-publish-json
verify_published_clawhub_package:
needs: [preview_plugins_clawhub, publish_plugins_clawhub]

View File

@@ -288,6 +288,7 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
OPENCLAW_NPM_PUBLISH_AUTH_MODE: trusted-publisher
run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"
- name: Verify published runtime

View File

@@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents: `sessions_send` now honors an explicit `sessionKey` when stale label metadata is also present, and denied session-id sends no longer echo the resolved canonical session key. Fixes #64699; refs #74009 and #41199. Thanks @Mintalix, @RevisitMoon, and @Mocha-s.
- Channel content boundaries: QQBot now strips reasoning/thinking tags before sending, preserving final answers while hiding internal model narration from users. (#89913, #90132) Thanks @openperf.
- Agents/MCP/providers: coerce non-text/image MCP tool-result blocks before they reach provider converters, preserving valid images and turning richer MCP content into text instead of malformed image blocks. (#90710, #90728) Thanks @RanSHammer and @849261680.
- Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until `message_start`, strip stale compaction thinking signatures before Anthropic replay, detect unsigned thinking-only stalls, refresh prompt fences after compaction writes, reject empty completion handoffs, preserve parent streaming-off overrides/shared progress commentary, forward heartbeat metadata to context-engine hooks, and cover Codex session/thread migration edge cases. (#90667, #90697, #90163, #90108, #89874, #89505, #90632, #89302, #90729, #90317, #90319) Thanks @openperf, @100yenadmin, and @ooiuuii.

View File

@@ -116,11 +116,19 @@ RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \
echo "/* A2UI bundle unavailable in this build */" > extensions/canvas/src/host/a2ui/a2ui.bundle.js && \
echo "stub" > extensions/canvas/src/host/a2ui/.bundle.hash && \
rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI)
RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \
fi && \
NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
pnpm_config_verify_deps_before_run=false pnpm qa:lab:build && \
mkdir -p dist/extensions/qa-lab/web && \
rm -rf dist/extensions/qa-lab/web/dist && \
cp -R extensions/qa-lab/web/dist dist/extensions/qa-lab/web/dist; \
fi
# Prune dev dependencies, omitted plugin runtime packages, and build-only
# metadata before copying runtime assets into the final image.

View File

@@ -72,7 +72,7 @@ final class CronJobsStore {
do {
if let status = try? await GatewayConnection.shared.cronStatus() {
self.schedulerEnabled = status.enabled
self.schedulerStorePath = status.storePath
self.schedulerStorePath = status.sqlitePath ?? status.storePath
self.schedulerNextWakeAtMs = status.nextWakeAtMs
}
self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true)

View File

@@ -1,4 +1,5 @@
import CryptoKit
import Darwin
import Foundation
import OSLog
import Security
@@ -229,6 +230,12 @@ enum ExecApprovalsStore {
private static let secureStateDirPermissions = 0o700
private static let fileLock = NSRecursiveLock()
private enum LegacyMigrationResult {
case notNeeded
case migrated
case blocked
}
private static func withFileLock<T>(_ body: () throws -> T) rethrows -> T {
self.fileLock.lock()
defer { self.fileLock.unlock() }
@@ -243,6 +250,195 @@ enum ExecApprovalsStore {
OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
}
private static func legacyStateDirURLs() -> [URL] {
if let home = OpenClawEnv.path("OPENCLAW_HOME") {
var urls = [
URL(fileURLWithPath: home, isDirectory: true)
.appendingPathComponent(".openclaw", isDirectory: true),
]
let osHomeURL = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".openclaw", isDirectory: true)
if !urls.contains(where: {
$0.standardizedFileURL.path == osHomeURL.standardizedFileURL.path
}) {
urls.append(osHomeURL)
}
return urls
}
return [
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".openclaw", isDirectory: true),
]
}
private static func legacyFileURLIfPending() -> URL? {
guard OpenClawEnv.path("OPENCLAW_STATE_DIR") != nil else { return nil }
let targetURL = self.fileURL()
for stateDirURL in self.legacyStateDirURLs() {
let legacyURL = stateDirURL
.appendingPathComponent("exec-approvals.json", isDirectory: false)
guard legacyURL.standardizedFileURL.path != targetURL.standardizedFileURL.path else {
continue
}
guard FileManager().fileExists(atPath: legacyURL.path) else { continue }
guard !FileManager().fileExists(atPath: targetURL.path) else { return nil }
return legacyURL
}
return nil
}
private static func unmigratedLegacyFallbackFile() -> ExecApprovalsFile {
ExecApprovalsFile(
version: 1,
socket: nil,
defaults: ExecApprovalsDefaults(
security: .deny,
ask: .always,
askFallback: .deny,
autoAllowSkills: nil),
agents: [:])
}
private static func isLegacyDefaultSocketPath(_ raw: String, legacyFileURL: URL) -> Bool {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return true }
let expanded = self.expandPath(trimmed)
let legacySocket = legacyFileURL.deletingLastPathComponent()
.appendingPathComponent("exec-approvals.sock", isDirectory: false)
.path
return URL(fileURLWithPath: expanded).standardizedFileURL.path
== URL(fileURLWithPath: legacySocket).standardizedFileURL.path
}
private static func hasSymlinkParent(_ url: URL) -> Bool {
var cursor = url.deletingLastPathComponent()
let manager = FileManager()
while true {
var isDirectory = ObjCBool(false)
if manager.fileExists(atPath: cursor.path, isDirectory: &isDirectory) {
if (try? manager.destinationOfSymbolicLink(atPath: cursor.path)) != nil {
return true
}
}
let parent = cursor.deletingLastPathComponent()
if parent.path == cursor.path { return false }
cursor = parent
}
}
private static func archiveMigratedLegacyFile(_ legacyURL: URL) throws -> URL {
let manager = FileManager()
var archiveURL = URL(fileURLWithPath: "\(legacyURL.path).migrated")
if manager.fileExists(atPath: archiveURL.path) {
archiveURL = URL(fileURLWithPath: "\(archiveURL.path)-\(UUID().uuidString)")
}
try manager.moveItem(at: legacyURL, to: archiveURL)
return archiveURL
}
private static func writeMigratedFileExclusively(_ data: Data, to targetURL: URL) throws -> Bool {
let tempURL = targetURL.deletingLastPathComponent()
.appendingPathComponent(".exec-approvals.migration.\(UUID().uuidString)")
let fd = open(tempURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR)
if fd == -1 {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
var closed = false
defer {
if !closed { close(fd) }
}
do {
try data.withUnsafeBytes { rawBuffer in
guard let base = rawBuffer.baseAddress else { return }
var offset = 0
while offset < rawBuffer.count {
let written = Darwin.write(
fd,
base.advanced(by: offset),
rawBuffer.count - offset)
if written < 0 {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
offset += written
}
}
close(fd)
closed = true
let copied = copyfile(
tempURL.path,
targetURL.path,
nil,
copyfile_flags_t(COPYFILE_EXCL))
if copied == -1 {
if errno == EEXIST {
try? FileManager().removeItem(at: tempURL)
return false
}
try? FileManager().removeItem(at: targetURL)
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
try? FileManager().removeItem(at: tempURL)
return true
} catch {
try? FileManager().removeItem(at: tempURL)
throw error
}
}
private static func migrateLegacyFileIfNeeded() -> LegacyMigrationResult {
guard let legacyURL = self.legacyFileURLIfPending() else { return .notNeeded }
let targetURL = self.fileURL()
do {
if self.hasSymlinkParent(targetURL) {
throw NSError(domain: "ExecApprovals", code: 10, userInfo: [
NSLocalizedDescriptionKey: "target path has a symlink parent",
])
}
let data = try Data(contentsOf: legacyURL)
var file = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
guard file.version == 1 else {
throw NSError(domain: "ExecApprovals", code: 11, userInfo: [
NSLocalizedDescriptionKey: "unsupported legacy approvals version",
])
}
file = self.normalizeIncoming(file)
let rawSocketPath = file.socket?.path?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if self.isLegacyDefaultSocketPath(rawSocketPath, legacyFileURL: legacyURL) {
if file.socket == nil {
file.socket = ExecApprovalsSocketConfig(path: nil, token: nil)
}
file.socket?.path = self.socketPath()
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let migrated = try encoder.encode(file)
self.ensureSecureStateDirectory()
try FileManager().createDirectory(
at: targetURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
if FileManager().fileExists(atPath: targetURL.path) { return .notNeeded }
let created = try self.writeMigratedFileExclusively(migrated, to: targetURL)
if !created { return .notNeeded }
try? FileManager().setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: targetURL.path)
do {
_ = try self.archiveMigratedLegacyFile(legacyURL)
} catch {
self.logger
.warning(
"exec approvals legacy archive failed: \(error.localizedDescription, privacy: .public)")
}
return .migrated
} catch {
self.logger
.error(
"exec approvals legacy migration failed: \(error.localizedDescription, privacy: .public)")
return .blocked
}
}
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -278,6 +474,14 @@ enum ExecApprovalsStore {
static func readSnapshot() -> ExecApprovalsSnapshot {
self.withFileLock {
if self.legacyFileURLIfPending() != nil {
let file = self.unmigratedLegacyFallbackFile()
return ExecApprovalsSnapshot(
path: self.fileURL().path,
exists: false,
hash: self.hashRaw(nil),
file: file)
}
let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsSnapshot(
@@ -322,6 +526,14 @@ enum ExecApprovalsStore {
static func loadFile() -> ExecApprovalsFile {
self.withFileLock {
if self.legacyFileURLIfPending() != nil {
switch self.migrateLegacyFileIfNeeded() {
case .migrated, .notNeeded:
break
case .blocked:
return self.unmigratedLegacyFallbackFile()
}
}
let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
@@ -361,6 +573,14 @@ enum ExecApprovalsStore {
static func ensureFile() -> ExecApprovalsFile {
self.withFileLock {
if self.legacyFileURLIfPending() != nil {
switch self.migrateLegacyFileIfNeeded() {
case .migrated, .notNeeded:
break
case .blocked:
return self.unmigratedLegacyFallbackFile()
}
}
self.ensureSecureStateDirectory()
let url = self.fileURL()
let existed = FileManager().fileExists(atPath: url.path)

View File

@@ -775,6 +775,7 @@ extension GatewayConnection {
struct CronSchedulerStatus: Decodable {
let enabled: Bool
let storePath: String
let sqlitePath: String?
let jobs: Int
let nextWakeAtMs: Int?
}

View File

@@ -16,6 +16,23 @@ struct ExecApprovalsStoreRefactorTests {
}
}
private func withTempHomeAndStateDir(
_ body: @escaping @Sendable (URL, URL) async throws -> Void) async throws
{
let root = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-home-state-\(UUID().uuidString)", isDirectory: true)
let home = root.appendingPathComponent("home", isDirectory: true)
let stateDir = root.appendingPathComponent("state", isDirectory: true)
defer { try? FileManager().removeItem(at: root) }
try await TestIsolation.withEnvValues([
"OPENCLAW_HOME": home.path,
"OPENCLAW_STATE_DIR": stateDir.path,
]) {
try await body(home, stateDir)
}
}
@Test
func `ensure file skips rewrite when unchanged`() async throws {
try await self.withTempStateDir { _ in
@@ -30,6 +47,50 @@ struct ExecApprovalsStoreRefactorTests {
}
}
@Test
func `ensure file migrates default approvals into custom state dir`() async throws {
try await self.withTempHomeAndStateDir { home, stateDir in
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
try FileManager().createDirectory(
at: legacyDir,
withIntermediateDirectories: true)
let legacySocket = legacyDir.appendingPathComponent("exec-approvals.sock").path
let legacyFile = legacyDir.appendingPathComponent("exec-approvals.json")
let legacyJson = """
{
"version": 1,
"socket": {
"path": "\(legacySocket)",
"token": "legacy-token"
},
"defaults": {
"security": "deny",
"ask": "always"
},
"agents": {
"main": {
"allowlist": [{ "pattern": "git status" }]
}
}
}
"""
try Data(legacyJson.utf8).write(to: legacyFile)
let file = ExecApprovalsStore.ensureFile()
let targetURL = ExecApprovalsStore.fileURL()
#expect(targetURL.path == stateDir.appendingPathComponent("exec-approvals.json").path)
#expect(FileManager().fileExists(atPath: targetURL.path))
#expect(file.socket?.path == stateDir.appendingPathComponent("exec-approvals.sock").path)
#expect(file.socket?.token == "legacy-token")
#expect(file.defaults?.security == .deny)
#expect(file.defaults?.ask == .always)
#expect(file.agents?["main"]?.allowlist?.map(\.pattern) == ["git status"])
#expect(!FileManager().fileExists(atPath: legacyFile.path))
#expect(FileManager().fileExists(atPath: "\(legacyFile.path).migrated"))
}
}
@Test
func `update allowlist accepts basename pattern`() async throws {
try await self.withTempStateDir { _ in

View File

@@ -1,2 +1,2 @@
8a2769df428906990ee0d1bf8b0423f2a099b053c64c816d092ff84d61e11633 plugin-sdk-api-baseline.json
28b798973f3fb2a5b33ccbb6e3c1ac0453fa234a3a1c6cdc27935c27639bd104 plugin-sdk-api-baseline.jsonl
2c783beea6b3cda3d79060739a923f9f39e7e8b5942123dd6b08a09143a587ca plugin-sdk-api-baseline.json
0b33af2cffb42abb46682fb71c8f214da220793f13d10a34d332e75ff99e8ce9 plugin-sdk-api-baseline.jsonl

View File

@@ -59,6 +59,14 @@ export CLICKCLACK_BOT_TOKEN="ccb_..."
openclaw gateway
```
If `plugins.allow` is a non-empty restrictive list, explicitly selecting
ClickClack in channel setup or running `openclaw plugins enable clickclack`
appends `clickclack` to that list. Onboarding installation uses the same
explicit-selection behavior. These paths do not override `plugins.deny` or a
global `plugins.enabled: false` setting. Direct `openclaw plugins install
clickclack` follows the normal plugin-install policy and also records ClickClack
in an existing allowlist.
## Multiple bots
Each account opens its own ClickClack realtime connection and uses its own bot token.

View File

@@ -183,7 +183,7 @@ The workflow installs OCM from a pinned release and Kova from `openclaw/Kova` at
- `mock-deep-profile`: CPU/heap/trace profiling for startup, gateway, and agent-turn hotspots.
- `live-openai-candidate`: a real OpenAI `openai/gpt-5.5` agent turn, skipped when `OPENAI_API_KEY` is unavailable.
The mock-provider lane also runs OpenClaw-native source probes after the Kova pass: gateway boot timing and memory across default, hook, and 50-plugin startup cases; bundled plugin import RSS, repeated mock-OpenAI `channel-chat-baseline` hello loops, and CLI startup commands against the booted gateway. When the previous published mock-provider source report is available for the tested ref, the source summary compares current RSS and heap values against that baseline and marks large RSS increases as `watch`. The source probe Markdown summary lives at `source/index.md` in the report bundle, with raw JSON beside it.
The mock-provider lane also runs OpenClaw-native source probes after the Kova pass: gateway boot timing and memory across default, hook, and 50-plugin startup cases; bundled plugin import RSS, repeated mock-OpenAI `channel-chat-baseline` hello loops, CLI startup commands against the booted gateway, and the SQLite state smoke performance probe. When the previous published mock-provider source report is available for the tested ref, the source summary compares current RSS and heap values against that baseline and marks large RSS increases as `watch`. The source probe Markdown summary lives at `source/index.md` in the report bundle, with raw JSON beside it.
Every lane uploads GitHub artifacts. When `CLAWGRIT_REPORTS_TOKEN` is configured, the workflow also commits `report.json`, `report.md`, bundles, `index.md`, and source-probe artifacts into `openclaw/clawgrit-reports` under `openclaw-performance/<tested-ref>/<run-id>-<attempt>/<lane>/`. The current tested-ref pointer is written as `openclaw-performance/<tested-ref>/latest-<lane>.json`.
@@ -452,7 +452,7 @@ For normal PRs, follow scoped CI/check evidence instead of treating parity as a
The `CodeQL` workflow is intentionally a narrow first-pass security scanner, not the full repository sweep. Daily, manual, and non-draft pull request guard runs scan Actions workflow code plus the highest-risk JavaScript/TypeScript surfaces with high-confidence security queries filtered to high/critical `security-severity`.
The pull request guard stays light: it only starts for changes under `.github/actions`, `.github/codeql`, `.github/workflows`, `packages`, or `src`, and it runs the same high-confidence security matrix as the scheduled workflow. Android and macOS CodeQL stay out of PR defaults.
The pull request guard stays light: it only starts for changes under `.github/actions`, `.github/codeql`, `.github/workflows`, `packages`, `scripts`, `src`, or process-owning bundled plugin runtime paths, and it runs the same high-confidence security matrix as the scheduled workflow. Android and macOS CodeQL stay out of PR defaults.
### Security categories
@@ -462,6 +462,7 @@ The pull request guard stays light: it only starts for changes under `.github/ac
| `/codeql-security-high/channel-runtime-boundary` | Core channel implementation contracts plus the channel plugin runtime, gateway, Plugin SDK, secrets, audit touchpoints |
| `/codeql-security-high/network-ssrf-boundary` | Core SSRF, IP parsing, network guard, web-fetch, and Plugin SDK SSRF policy surfaces |
| `/codeql-security-high/mcp-process-tool-boundary` | MCP servers, process execution helpers, outbound delivery, and agent tool-execution gates |
| `/codeql-security-high/process-exec-boundary` | Local shell, process spawn helpers, subprocess-owning bundled plugin runtimes, and workflow script glue |
| `/codeql-security-high/plugin-trust-boundary` | Plugin install, loader, manifest, registry, package-manager install, source-loading, and Plugin SDK package contract trust surfaces |
### Platform-specific security shards

View File

@@ -27,7 +27,7 @@ Use it when you want to:
- inspect the local requested policy, host approvals file, and effective merge
- apply a local preset such as YOLO or deny-all
- synchronize local `tools.exec.*` and local `~/.openclaw/exec-approvals.json`
- synchronize local `tools.exec.*` and the local host approvals file
Examples:
@@ -183,7 +183,9 @@ Targeting notes:
- `--node` uses the same resolver as `openclaw nodes` (id, name, ip, or id prefix).
- `--agent` defaults to `"*"`, which applies to all agents.
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
- Approvals files are stored per host at `~/.openclaw/exec-approvals.json`.
- Approvals files are stored per host in the OpenClaw state dir
(`$OPENCLAW_STATE_DIR/exec-approvals.json`, or
`~/.openclaw/exec-approvals.json` when the variable is unset).
## Related

View File

@@ -162,7 +162,8 @@ The node host stores its node id, token, display name, and gateway connection in
`system.run` is gated by local exec approvals:
- `~/.openclaw/exec-approvals.json`
- `$OPENCLAW_STATE_DIR/exec-approvals.json`, or
`~/.openclaw/exec-approvals.json` when the variable is unset
- [Exec approvals](/tools/exec-approvals)
- `openclaw approvals --node <id|name|ip>` (edit from the Gateway)

View File

@@ -130,12 +130,14 @@ install method aligned:
missing or older than the current stable release.
The Gateway core auto-updater (when enabled via config) launches the CLI update path
outside the live Gateway request handler. Control-plane `update.run` package-manager
updates also use a managed-service handoff instead of replacing the package tree
inside the live Gateway process. The Gateway starts a detached helper, exits,
and the helper runs the normal `openclaw update --yes --json` CLI path from
outside the Gateway process tree. If that handoff is unavailable, `update.run`
returns a structured response with the safe shell command to run manually.
outside the live Gateway request handler. Control-plane `update.run`
package-manager updates and supervised git-checkout updates also use a
managed-service handoff instead of replacing the package tree or rebuilding
`dist/` inside the live Gateway process. The Gateway starts a detached helper,
exits, and the helper runs the normal `openclaw update --yes --json` CLI path
from outside the Gateway process tree. If that handoff is unavailable,
`update.run` returns a structured response with the safe shell command to run
manually.
For package-manager installs, `openclaw update` resolves the target package
version before invoking the package manager. npm global installs use a staged
@@ -150,29 +152,33 @@ installed OpenClaw build while leaving full plugin-command completion rebuilds t
explicit `openclaw completion --write-state` runs.
When a local managed Gateway service is installed and restart is enabled,
package-manager updates stop the running service before replacing the package
tree, then refresh the service metadata from the updated install, restart the
service, and verify the restarted Gateway reports the expected version before
reporting `Gateway: restarted and verified.`. On macOS, the post-update check
also verifies the LaunchAgent is loaded/running for the active profile and the
configured loopback port is healthy. If the plist is installed but launchd is
not supervising it, OpenClaw re-bootstraps the LaunchAgent automatically, then
reruns the health/version/channel readiness checks. A fresh bootstrap loads the
RunAtLoad job directly, so update recovery does not immediately `kickstart -k`
the newly spawned Gateway. If the Gateway still does not become healthy, the
command exits non-zero and prints the restart log path plus explicit restart,
reinstall, and package rollback instructions. If restart cannot run, the command
prints `Gateway: restart skipped (...)` or `Gateway: restart failed: ...` with a
manual `openclaw gateway restart` hint. With `--no-restart`,
package replacement still runs but the managed service is not stopped or
restarted, so the running Gateway may keep old code until you restart it
manually.
package-manager and git-checkout updates stop the running service before
replacing the package tree or mutating the checkout/build output. The updater
then refreshes the service metadata from the updated install, restarts the
service, and verifies the restarted Gateway before reporting
`Gateway: restarted and verified.`. Package-manager updates additionally verify
the restarted Gateway reports the expected package version; git-checkout updates
verify gateway health and service readiness after the rebuild. On macOS, the
post-update check also verifies the LaunchAgent is loaded/running for the active
profile and the configured loopback port is healthy. If the plist is installed
but launchd is not supervising it, OpenClaw re-bootstraps the LaunchAgent
automatically, then reruns the health/version/channel readiness checks. A fresh
bootstrap loads the RunAtLoad job directly, so update recovery does not
immediately `kickstart -k` the newly spawned Gateway. If the Gateway still does
not become healthy, the command exits non-zero and prints the restart log path
plus explicit restart, reinstall, and package rollback instructions. If restart
cannot run, the command prints `Gateway: restart skipped (...)` or
`Gateway: restart failed: ...` with a manual `openclaw gateway restart` hint.
With `--no-restart`, package replacement or git rebuild still runs but the
managed service is not stopped or restarted, so the running Gateway may keep old
code until you restart it manually.
### Control-plane response shape
When `update.run` is invoked through the Gateway control plane on a
package-manager install, the handler reports the handoff initiation separately
from the CLI update that continues after the Gateway exits:
package-manager install or supervised git checkout, the handler reports the
handoff initiation separately from the CLI update that continues after the
Gateway exits:
- `ok: true`, `result.status: "skipped"`,
`result.reason: "managed-service-handoff-started"`, and
@@ -181,8 +187,11 @@ from the CLI update that continues after the Gateway exits:
`openclaw update --yes --json` outside the live service process.
- `ok: false`, `result.reason: "managed-service-handoff-unavailable"`, and
`handoff.status: "unavailable"` mean OpenClaw could not find a supervising
service boundary for a safe handoff. The response includes
`handoff.command`, the shell command to run from outside the Gateway.
service boundary and durable service identity for a safe handoff. For
example, systemd handoff requires the OpenClaw unit identity
(`OPENCLAW_SYSTEMD_UNIT`), not only ambient systemd process markers. The
response includes `handoff.command`, the shell command to run from outside the
Gateway.
- `ok: false`, `result.reason: "managed-service-handoff-failed"` means the
Gateway tried to create the handoff but could not spawn the detached helper.
@@ -193,8 +202,8 @@ health checks complete. During the handoff, the sentinel can carry
restarted Gateway keeps polling it and only fires the continuation after the CLI
has verified service health and rewritten the sentinel with the final `ok`
result. `openclaw status` and `openclaw status --all` show an `Update restart`
row while that sentinel is pending or failed, and `update.status` returns the
latest cached sentinel.
row while that sentinel is pending or failed, and `update.status` refreshes and
returns the latest sentinel.
## Git checkout flow

View File

@@ -35,6 +35,7 @@ openclaw wiki status
openclaw wiki doctor
openclaw wiki init
openclaw wiki ingest ./notes/alpha.md
openclaw wiki okf import ./knowledge-catalog/okf/bundles/ga4
openclaw wiki compile
openclaw wiki lint
openclaw wiki search "alpha"
@@ -104,6 +105,31 @@ Notes:
- imported source pages keep provenance in frontmatter
- auto-compile can run after ingest when enabled
### `wiki okf import <path>`
Import an unpacked Open Knowledge Format bundle into wiki concept pages.
The importer reads every non-reserved `.md` concept document in the OKF
directory tree, requires a non-empty `type` field, and treats unknown OKF
`type` values as generic concepts. Reserved OKF `index.md` and `log.md` files
are not imported as concepts.
Imported pages are flattened under `concepts/` so existing wiki compile,
search, get, digest, and dashboard flows see them immediately. The original OKF
concept ID, `type`, `resource`, `tags`, timestamp, source path, and full
frontmatter are preserved in the page frontmatter. Internal OKF markdown links
are rewritten to the generated wiki pages; broken or external links are left
unchanged.
Examples:
```bash
openclaw wiki okf import ./bundles/ga4
openclaw wiki okf import ./bundles/ga4 --json
openclaw wiki search "BigQuery Table" --mode source-evidence --json
openclaw wiki get <path-from-json-result>
```
### `wiki compile`
Rebuild indexes, related blocks, dashboards, and compiled digests.
@@ -233,6 +259,8 @@ These require the official `obsidian` CLI on `PATH` when
- Use `wiki lint` before trusting contradictory or low-confidence content.
- Use `wiki compile` after bulk imports or source changes when you want fresh
dashboards and compiled digests immediately.
- Use `wiki okf import` when a data catalog, documentation export, or agent
enrichment pipeline already emits OKF markdown bundles.
- Use `wiki bridge import` when bridge mode depends on newly exported memory
artifacts.

View File

@@ -368,6 +368,7 @@ Kimi K2 model IDs:
[//]: # "moonshot-kimi-k2-model-refs:start"
- `moonshot/kimi-k2.6`
- `moonshot/kimi-k2.7-code`
- `moonshot/kimi-k2.5`
- `moonshot/kimi-k2-thinking`
- `moonshot/kimi-k2-thinking-turbo`

View File

@@ -374,7 +374,7 @@ The implicit default set always covers canary, mention gating, native command re
Output artifacts:
- `telegram-qa-report.md`
- `telegram-qa-summary.json` - includes per-reply RTT (driver send → observed SUT reply) starting with the canary.
- `qa-evidence.json` - evidence entries for the live transport checks, including profile, coverage, provider, channel, artifacts, result, and RTT fields.
- `telegram-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT=1`.
Package RTT comparison uses the same Telegram credential contract while keeping
@@ -447,7 +447,7 @@ pnpm openclaw qa discord \
Output artifacts:
- `discord-qa-report.md`
- `discord-qa-summary.json`
- `qa-evidence.json` - evidence entries for the live transport checks.
- `discord-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_DISCORD_CAPTURE_CONTENT=1`.
- `discord-qa-reaction-timelines.json` and `discord-status-reactions-tool-only-timeline.png` when the status-reaction scenario runs.
@@ -495,7 +495,7 @@ Scenarios (`extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts`):
Output artifacts:
- `slack-qa-report.md`
- `slack-qa-summary.json`
- `qa-evidence.json` - evidence entries for the live transport checks.
- `slack-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_SLACK_CAPTURE_CONTENT=1`.
- `approval-checkpoints/` - only when Mantis sets
`OPENCLAW_QA_SLACK_APPROVAL_CHECKPOINT_DIR`; contains checkpoint JSON,
@@ -740,7 +740,7 @@ poll and upload-file coverage run through deterministic gateway `poll` and
Output artifacts:
- `whatsapp-qa-report.md`
- `whatsapp-qa-summary.json`
- `qa-evidence.json` - evidence entries for the live transport checks.
- `whatsapp-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT=1`.
### Convex credential pool
@@ -787,9 +787,10 @@ the source of truth for one test run and should define:
- docs and code refs
- optional plugin requirements
- optional gateway config patch
- the executable `qa-flow`
- an executable `qa-flow` block for flow scenarios, or `execution.kind`/`execution.path`
for Vitest and Playwright scenarios
The reusable runtime surface that backs `qa-flow` is allowed to stay generic
The reusable runtime surface that backs `qa-flow` blocks is allowed to stay generic
and cross-cutting. For example, markdown scenarios can combine transport-side
helpers with browser-side helpers that drive the embedded Control UI through the
Gateway `browser.request` seam without adding a special-case runner.
@@ -915,6 +916,7 @@ The report should answer:
For the inventory of available scenarios - useful when sizing follow-up work or wiring a new transport - run `pnpm openclaw qa coverage` (add `--json` for machine-readable output).
When choosing focused proof for a touched behavior or file path, run `pnpm openclaw qa coverage --match <query>`.
The match report searches scenario metadata, docs refs, code refs, coverage IDs, plugins, and provider requirements, then prints matching `qa suite --scenario ...` targets.
Every `qa suite` scenario execution writes a `qa-evidence.json` artifact. Flow scenarios also write `qa-suite-summary.json` for existing suite/report tooling; scenarios that declare `execution.kind: vitest` or `execution.kind: playwright` run the matching test path and write `qa-vitest-report.md` or `qa-playwright-report.md` plus per-scenario logs.
Treat it as a discovery aid, not a gate replacement; the selected scenario still needs the right provider mode, live transport, Multipass, Testbox, or release lane for the behavior under test.
For character and style checks, run the same scenario across multiple live model

View File

@@ -30,6 +30,23 @@ title: "Usage tracking"
- CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
- macOS menu bar: "Usage" section under Context (only if available).
## Custom `/usage full` footer
Set `messages.usageTemplate` to customize the per-response `/usage full`
footer. The value can be an inline template object or a JSON file path:
```json
{
"messages": {
"usageTemplate": "~/.openclaw/usage-footer.json"
}
}
```
Templates read the `openclaw.usageLine.v1` contract and can use `scales`,
`aliases`, and `output.surfaces` to render channel-specific footers. Missing,
unreadable, invalid, or empty templates fall back to the built-in usage line.
## Providers + credentials
- **Anthropic (Claude)**: OAuth tokens in auth profiles.

View File

@@ -1374,9 +1374,7 @@
"pages": [
"clawhub/cli",
"clawhub/publishing",
"clawhub/plugin-validation-fixes",
"clawhub/skill-format",
"clawhub/soul-format",
"clawhub/auth",
"clawhub/telemetry",
"clawhub/troubleshooting"

View File

@@ -493,6 +493,8 @@ example `~/.agents/skills/manager -> ~/Projects/manager/skills`.
- `extraDirs` scans the sibling repo as an explicit skill root.
- `allowSymlinkTargets` lets symlinked skill folders resolve into that trusted
real target root without allowing arbitrary symlink escapes.
- To let Skill Workshop apply write through the same trusted symlink target,
set `skills.workshop.allowSymlinkTargetWrites: true`.
## Common patterns

View File

@@ -200,6 +200,9 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
nodeManager: "npm", // npm | pnpm | yarn | bun
allowUploadedArchives: false,
},
workshop: {
allowSymlinkTargetWrites: false,
},
entries: {
"image-lab": {
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string
@@ -216,6 +219,8 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
- `load.extraDirs`: extra shared skill roots (lowest precedence).
- `load.allowSymlinkTargets`: trusted real target roots that skill symlinks may
resolve into when the link lives outside its configured source root.
- `workshop.allowSymlinkTargetWrites`: allows Skill Workshop apply to write
through already-trusted symlink targets (default: false).
- `install.preferBrew`: when true, prefer Homebrew installers when `brew` is
available before falling back to other installer kinds.
- `install.nodeManager`: node installer preference for `metadata.openclaw.install`

View File

@@ -42,6 +42,21 @@ health commands above for live connectivity checks.
- `channels.<provider>.accounts.<accountId>.healthMonitor.enabled`: multi-account override that wins over the channel-level setting.
- These per-channel overrides apply to the built-in channel monitors that expose them today: Discord, Google Chat, iMessage, Microsoft Teams, Signal, Slack, Telegram, and WhatsApp.
## Uptime monitoring
External uptime monitoring services should use the dedicated `/health` endpoint, not `/v1/chat/completions`.
- **DO use:** `GET /health` — instant response, no session created, no LLM call, returns `{"ok":true,"status":"live"}`
- **DON'T use:** `/v1/chat/completions` for health checks — each request creates a full agent session with skill snapshot, context assembly, and LLM calls
When no `x-openclaw-session-key` header or `user` field is provided, `/v1/chat/completions` generates a new random session for each request. Monitoring services that ping every 15 minutes create ~96 sessions/day, each consuming 422KB. Over time this causes session store bloat and can lead to context window overflow.
### Monitoring service setup examples
- **BetterStack:** Set health check URL to `https://<your-gateway-host>:<port>/health`
- **UptimeRobot:** Add a new HTTP monitor with URL `https://<your-gateway-host>:<port>/health`
- **Generic:** Any HTTP GET to `/health` returns 200 with `{"ok":true}` when the gateway is healthy
## When something fails
- `logged out` or status 409515 → relink with `openclaw channels logout` then `openclaw channels login`.

View File

@@ -75,6 +75,7 @@ Auth matrix:
- honor `x-openclaw-scopes` when the header is present
- fall back to the normal operator default scope set when the header is absent
- only lose owner semantics when the caller explicitly narrows scopes and omits `operator.admin`
- require `operator.admin` for owner-level request controls such as `x-openclaw-model`
See [Security](/gateway/security) and [Remote access](/gateway/remote).
@@ -96,7 +97,7 @@ OpenClaw treats the OpenAI `model` field as an **agent target**, not a raw provi
Optional request headers:
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent.
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent. Shared-secret bearer callers can use this header. Identity-bearing callers, such as trusted-proxy or private no-auth ingress requests with `x-openclaw-scopes`, need `operator.admin`; write-only callers get `403 missing scope: operator.admin`.
- `x-openclaw-agent-id: <agentId>` remains supported as a compatibility override.
- `x-openclaw-session-key: <sessionKey>` fully controls session routing.
- `x-openclaw-message-channel: <channel>` sets the synthetic ingress channel context for channel-aware prompts and policies.
@@ -178,7 +179,7 @@ This is the highest-leverage compatibility set for self-hosted frontends and too
</Accordion>
<Accordion title="How do I override the backend model?">
Use `x-openclaw-model`.
Use `x-openclaw-model`. This is an owner-level override: it works with the Gateway shared-secret bearer token/password path, and it requires `operator.admin` on identity-bearing HTTP paths such as trusted proxy auth.
Examples:
`x-openclaw-model: openai/gpt-5.4`
@@ -191,7 +192,7 @@ This is the highest-leverage compatibility set for self-hosted frontends and too
`/v1/embeddings` uses the same agent-target `model` ids.
Use `model: "openclaw/default"` or `model: "openclaw/<agentId>"`.
When you need a specific embedding model, send it in `x-openclaw-model`.
When you need a specific embedding model, send it in `x-openclaw-model` from a shared-secret caller or an identity-bearing caller with `operator.admin`.
Without that header, the request passes through to the selected agent's normal embedding setup.
</Accordion>
@@ -285,7 +286,7 @@ Expected behavior:
- `GET /v1/models` should list `openclaw/default`
- Open WebUI should use `openclaw/default` as the chat model id
- If you want a specific backend provider/model for that agent, set the agent's normal default model or send `x-openclaw-model`
- If you want a specific backend provider/model for that agent, set the agent's normal default model or send `x-openclaw-model` from a shared-secret caller or an identity-bearing caller with `operator.admin`
Quick smoke:
@@ -370,7 +371,7 @@ Notes:
- `/v1/models` returns OpenClaw agent targets, not raw provider catalogs.
- `openclaw/default` is always present so one stable id works across environments.
- Backend provider/model overrides belong in `x-openclaw-model`, not the OpenAI `model` field.
- Backend provider/model overrides belong in `x-openclaw-model`, not the OpenAI `model` field. On identity-bearing HTTP auth paths, this header requires `operator.admin`.
- `/v1/embeddings` supports `input` as a string or array of strings.
## Related

View File

@@ -411,8 +411,8 @@ enumeration of `src/gateway/server-methods/*.ts`.
- `config.apply` validates + replaces the full config payload.
- `config.schema` returns the live config schema payload used by Control UI and CLI tooling: schema, `uiHints`, version, and generation metadata, including plugin + channel schema metadata when the runtime can load it. The schema includes field `title` / `description` metadata derived from the same labels and help text used by the UI, including nested object, wildcard, array-item, and `anyOf` / `oneOf` / `allOf` composition branches when matching field documentation exists.
- `config.schema.lookup` returns a path-scoped lookup payload for one config path: normalized path, a shallow schema node, matched hint + `hintPath`, optional `reloadKind`, and immediate child summaries for UI/CLI drill-down. `reloadKind` is one of `restart`, `hot`, or `none` and mirrors the Gateway config reload planner for the requested path. Lookup schema nodes keep the user-facing docs and common validation fields (`title`, `description`, `type`, `enum`, `const`, `format`, `pattern`, numeric/string/array/object bounds, and flags like `additionalProperties`, `deprecated`, `readOnly`, `writeOnly`). Child summaries expose `key`, normalized `path`, `type`, `required`, `hasChildren`, optional `reloadKind`, plus the matched `hint` / `hintPath`.
- `update.run` runs the gateway update flow and schedules a restart only when the update itself succeeded; callers with a session can include `continuationMessage` so startup resumes one follow-up agent turn through the restart continuation queue. Package-manager updates from the control plane use a detached managed-service handoff instead of replacing the package tree inside the live Gateway. A started handoff returns `ok: true` with `result.reason: "managed-service-handoff-started"` and `handoff.status: "started"`; unavailable or failed handoffs return `ok: false` with `managed-service-handoff-unavailable` or `managed-service-handoff-failed`, plus `handoff.command` when a manual shell update is required. During a started handoff, the restart sentinel may briefly report `stats.reason: "restart-health-pending"`; the continuation is delayed until the CLI verifies the restarted Gateway and writes the final `ok` sentinel.
- `update.status` returns the latest cached update restart sentinel, including the post-restart running version when available.
- `update.run` runs the gateway update flow and schedules a restart only when the update itself succeeded; callers with a session can include `continuationMessage` so startup resumes one follow-up agent turn through the restart continuation queue. Package-manager updates and supervised git-checkout updates from the control plane use a detached managed-service handoff instead of replacing the package tree or mutating checkout/build output inside the live Gateway. A started handoff returns `ok: true` with `result.reason: "managed-service-handoff-started"` and `handoff.status: "started"`; unavailable or failed handoffs return `ok: false` with `managed-service-handoff-unavailable` or `managed-service-handoff-failed`, plus `handoff.command` when a manual shell update is required. An unavailable handoff means OpenClaw lacks a safe supervisor boundary or durable service identity, such as `OPENCLAW_SYSTEMD_UNIT` for systemd. During a started handoff, the restart sentinel may briefly report `stats.reason: "restart-health-pending"`; the continuation is delayed until the CLI verifies the restarted Gateway and writes the final `ok` sentinel.
- `update.status` refreshes and returns the latest update restart sentinel, including the post-restart running version when available.
- `wizard.start`, `wizard.next`, `wizard.status`, and `wizard.cancel` expose the onboarding wizard over WS RPC.
</Accordion>

View File

@@ -93,7 +93,7 @@ exhaustive):
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` fails closed when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
| `tools.exec.security_full_configured` | warn/critical | Host exec is running with `security="full"` | `tools.exec.security`, `agents.list[].tools.exec.security` | no |
| `tools.exec.fs_tools_disabled_but_exec_enabled` | warn | Filesystem tool policy does not make shell execution read-only | `tools.deny`, `agents.list[].tools.deny`, `agents.*.sandbox.workspaceAccess` | no |
| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | `~/.openclaw/exec-approvals.json` | no |
| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | host approvals file | no |
| `tools.exec.allowlist_interpreter_without_strict_inline_eval` | warn | Interpreter allowlists permit inline eval without forced reapproval | `tools.exec.strictInlineEval`, `agents.list[].tools.exec.strictInlineEval`, exec approvals allowlist | no |
| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no |
| `tools.exec.safe_bins_broad_behavior` | warn | Broad-behavior tools in `safeBins` weaken the low-risk stdin-filter trust model | `tools.exec.safeBins`, `agents.list[].tools.exec.safeBins` | no |

View File

@@ -951,7 +951,7 @@ Important boundary note:
- Treat credentials that can call `/v1/chat/completions`, `/v1/responses`, plugin routes such as `/api/v1/admin/rpc`, or `/api/channels/*` as full-access operator secrets for that gateway.
- On the OpenAI-compatible HTTP surface, shared-secret bearer auth restores the full default operator scopes (`operator.admin`, `operator.approvals`, `operator.pairing`, `operator.read`, `operator.talk.secrets`, `operator.write`) and owner semantics for agent turns; narrower `x-openclaw-scopes` values do not reduce that shared-secret path.
- Per-request scope semantics on HTTP only apply when the request comes from an identity-bearing mode such as trusted proxy auth, or from an explicitly no-auth private ingress.
- In those identity-bearing modes, omitting `x-openclaw-scopes` falls back to the normal operator default scope set; send the header explicitly when you want a narrower scope set.
- In those identity-bearing modes, omitting `x-openclaw-scopes` falls back to the normal operator default scope set; send the header explicitly when you want a narrower scope set. Owner-level OpenAI-compatible headers such as `x-openclaw-model` require `operator.admin` when scopes are narrowed.
- `/tools/invoke` and HTTP session history endpoints follow the same shared-secret rule: token/password bearer auth is treated as full operator access there too, while identity-bearing modes still honor declared scopes.
- Do not share these credentials with untrusted callers; prefer separate gateways per trust boundary.

View File

@@ -154,6 +154,10 @@ Do not use broad targets such as `~`, `/`, or a whole synced project folder.
Keep `allowSymlinkTargets` scoped to the real skill root that contains trusted
`SKILL.md` directories.
If Skill Workshop apply should also write through those trusted symlinked
workspace skill paths, enable `skills.workshop.allowSymlinkTargetWrites`. Keep
it disabled for read-only shared skill roots.
Related:
- [Skills config](/tools/skills-config#symlinked-sibling-repos)

View File

@@ -18,11 +18,13 @@ most Linux-compatible Gateway runtime.
Windows Hub is the native WinUI companion app for Windows 10 20H2+ and Windows 11. It installs without administrator privileges and is published with signed
x64 and ARM64 installers on OpenClaw releases.
Download the latest stable installer:
Download the latest stable installer from the [OpenClaw releases page](https://github.com/openclaw/openclaw/releases):
- [OpenClawCompanion-Setup-x64.exe](https://github.com/openclaw/openclaw/releases/latest/download/OpenClawCompanion-Setup-x64.exe)
- [OpenClawCompanion-Setup-arm64.exe](https://github.com/openclaw/openclaw/releases/latest/download/OpenClawCompanion-Setup-arm64.exe)
- [Checksums](https://github.com/openclaw/openclaw/releases/latest/download/OpenClawCompanion-SHA256SUMS.txt)
- [OpenClawCompanion-Setup-x64.exe](https://github.com/openclaw/openclaw/releases/download/v2026.6.5/OpenClawCompanion-Setup-x64.exe)
- [OpenClawCompanion-Setup-arm64.exe](https://github.com/openclaw/openclaw/releases/download/v2026.6.5/OpenClawCompanion-Setup-arm64.exe)
- [Checksums](https://github.com/openclaw/openclaw/releases/download/v2026.6.5/OpenClawCompanion-SHA256SUMS.txt)
If a download link above returns a 404, visit the [releases page](https://github.com/openclaw/openclaw/releases) and look for the `OpenClawCompanion-Setup-*` assets on the latest release.
After install, launch **OpenClaw Companion** from the Start menu or the system
tray. The installer also adds shortcuts for Gateway Setup, Chat, Settings,

View File

@@ -128,6 +128,10 @@ Current compatibility records include:
- legacy runtime aliases such as `api.runtime.taskFlow`,
`api.runtime.subagent.getSession`, `api.runtime.stt`, and deprecated
`api.runtime.config.loadConfig()` / `api.runtime.config.writeConfigFile(...)`
- WhatsApp `WebInboundMessage` flat callback fields such as `body`, `chatId`,
`reply(...)`, and `mediaPath` while callback consumers migrate to the nested
`WebInboundCallbackMessage` `event`, `payload`, `quote`, `group`, and
`platform` contexts
- legacy memory-plugin split registration while memory plugins move to
`registerMemoryCapability`
- legacy memory-specific embedding provider registration while embedding
@@ -160,6 +164,33 @@ New plugin code should prefer the replacement listed in the registry and in the
specific migration guide. Existing plugins can keep using a compatibility path
until the docs, diagnostics, and release notes announce a removal window.
### WhatsApp Inbound Callback Flat Aliases
WhatsApp runtime callbacks deliver `WebInboundMessage`: the canonical nested
`event`, `payload`, `quote`, `group`, and `platform` contexts plus deprecated
flat aliases for the shipped callback fields. New callback code should read the
nested contexts. Code that constructs clean nested callback messages can use
`WebInboundCallbackMessage`; compatibility listeners that still inject old flat
test or plugin messages should use `LegacyFlatWebInboundMessage` or
`WebInboundMessageInput`.
The flat aliases remain available until **2026-08-30**. That removal window
applies only to flat alias access; the nested callback shape is the canonical
runtime contract. The TypeScript `@deprecated` annotations on each flat alias
name its exact nested replacement. Common examples:
- `id`, `timestamp`, and `isBatched` move under `event`.
- `body`, `mediaPath`, `mediaType`, `mediaFileName`, `mediaUrl`, `location`, and
`untrustedStructuredContext` move under `payload`.
- `to`, `chatId`, sender/self fields, `sendComposing`, `reply(...)`, and
`sendMedia(...)` move under `platform`.
- `replyTo*` fields move under `quote`, and group subject/participant/mention
fields move under `group`.
`payload.untrustedStructuredContext` is extracted from inbound provider payloads.
Plugins should inspect the `label`, `source`, and `type` before treating its
`payload` as authoritative.
## Release notes
Release notes should include upcoming plugin deprecations with target dates and

View File

@@ -425,6 +425,10 @@ even when the channel payload has no visible text/caption. Rewriting that
`content` updates the hook-visible transcript only; it is not rendered as a
media caption.
`reply_payload_sending` events may include `usageState`, a best-effort live
per-turn model/usage/context snapshot. Durable delivery, recovered replay, and
replies without exact run correlation omit it.
Message hook contexts expose stable correlation fields when available:
`ctx.sessionKey`, `ctx.runId`, `ctx.messageId`, `ctx.senderId`, `ctx.trace`,
`ctx.traceId`, `ctx.spanId`, `ctx.parentSpanId`, and `ctx.callDepth`. Inbound

View File

@@ -25,6 +25,7 @@ less like a pile of Markdown files.
- Page-level provenance, confidence, contradictions, and open questions
- Compiled digests for agent/runtime consumers
- Wiki-native search/get/apply/lint tools
- Open Knowledge Format imports into compiled wiki concepts
- Optional bridge mode that imports public artifacts from the active memory plugin
- Optional Obsidian-friendly render mode and CLI integration
@@ -135,6 +136,34 @@ The main page groups are:
- `syntheses/` for compiled summaries and maintained rollups
- `reports/` for generated dashboards
## Open Knowledge Format imports
`memory-wiki` can import unpacked Open Knowledge Format bundles with:
```bash
openclaw wiki okf import ./bundles/ga4
```
This is the cleanest fit when a data catalog, documentation crawler, or
enrichment agent already produces OKF: keep OKF as the portable exchange
artifact, then let `memory-wiki` turn it into OpenClaw-native concept pages and
compiled digests.
The importer follows the OKF v0.1 shape:
- non-reserved `.md` files are concept documents
- each imported concept needs a non-empty `type` frontmatter field
- unknown OKF `type` values are accepted
- reserved `index.md` and `log.md` files are not imported as concepts
- broken or external markdown links are preserved
Imported concept pages are flattened under `concepts/` so the existing compile,
search, get, dashboard, and prompt-digest paths see them without adding a second
wiki tree. Each page keeps the original OKF concept ID, source path, `type`,
`resource`, `tags`, timestamp, and full producer frontmatter. Internal OKF links
are rewritten to the generated wiki concept pages and also emitted as structured
`relationships` entries with `kind: okf-link`.
## Structured claims and evidence
Pages can carry structured `claims` frontmatter, not just freeform text.

View File

@@ -137,7 +137,7 @@ Each entry lists the package, distribution route, and description.
- **[mattermost](/plugins/reference/mattermost)** (`@openclaw/mattermost`) - included in OpenClaw. Adds the Mattermost channel surface for sending and receiving OpenClaw messages.
- **[memory-core](/plugins/reference/memory-core)** (`@openclaw/memory-core`) - included in OpenClaw. Adds file-backed memory search tools.
- **[memory-core](/plugins/reference/memory-core)** (`@openclaw/memory-core`) - included in OpenClaw. Adds agent-callable tools.
- **[memory-wiki](/plugins/reference/memory-wiki)** (`@openclaw/memory-wiki`) - included in OpenClaw. Persistent wiki compiler and Obsidian-friendly knowledge vault for OpenClaw.
@@ -267,10 +267,10 @@ Each entry lists the package, distribution route, and description.
- **[googlechat](/plugins/reference/googlechat)** (`@openclaw/googlechat`) - npm; ClawHub. OpenClaw Google Chat channel plugin for spaces and direct messages.
- **[llama-cpp](/plugins/reference/llama-cpp)** (`@openclaw/llama-cpp-provider`) - npm; ClawHub. OpenClaw llama.cpp embedding provider plugin.
- **[line](/plugins/reference/line)** (`@openclaw/line`) - npm; ClawHub. OpenClaw LINE channel plugin for LINE Bot API chats.
- **[llama-cpp](/plugins/reference/llama-cpp)** (`@openclaw/llama-cpp-provider`) - npm; ClawHub. Local GGUF embeddings through node-llama-cpp.
- **[lobster](/plugins/reference/lobster)** (`@openclaw/lobster`) - npm; ClawHub. Lobster workflow tool plugin for typed pipelines and resumable approvals.
- **[matrix](/plugins/reference/matrix)** (`@openclaw/matrix`) - ClawHub: `clawhub:@openclaw/matrix`; npm. OpenClaw Matrix channel plugin for rooms and direct messages.

View File

@@ -18,8 +18,12 @@ OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.
providers: anthropic-vertex
<!-- openclaw-plugin-reference:manual-start -->
## Claude Fable 5
Use `anthropic-vertex/claude-fable-5` where the model is available in your Google Cloud region.
Fable 5 always uses adaptive thinking and defaults to `high` effort. `/think off` and
`/think minimal` use `low` effort because the model does not support disabling thinking.
<!-- openclaw-plugin-reference:manual-end -->

View File

@@ -1,13 +1,13 @@
---
summary: "OpenClaw llama.cpp embedding provider plugin."
summary: "Local GGUF embeddings through node-llama-cpp."
read_when:
- You are installing, configuring, or auditing the llama-cpp plugin
title: "llama-cpp plugin"
title: "Llama Cpp plugin"
---
# llama-cpp plugin
# Llama Cpp plugin
OpenClaw llama.cpp embedding provider plugin.
Local GGUF embeddings through node-llama-cpp.
## Distribution
@@ -20,4 +20,4 @@ contracts: embeddingProviders
## Related docs
- [llama.cpp Provider](/plugins/llama-cpp)
- [llama-cpp](/plugins/llama-cpp)

View File

@@ -1,5 +1,5 @@
---
summary: "Adds file-backed memory search tools."
summary: "Adds agent-callable tools."
read_when:
- You are installing, configuring, or auditing the memory-core plugin
title: "Memory Core plugin"
@@ -7,7 +7,7 @@ title: "Memory Core plugin"
# Memory Core plugin
Adds file-backed memory search tools.
Adds agent-callable tools.
## Distribution

View File

@@ -1,5 +1,5 @@
---
summary: "Use Microsoft Foundry chat and MAI image deployments from OpenClaw."
summary: "Adds Microsoft Foundry model provider support to OpenClaw."
read_when:
- You are installing, configuring, or auditing the microsoft-foundry plugin
title: "Microsoft Foundry plugin"
@@ -7,9 +7,7 @@ title: "Microsoft Foundry plugin"
# Microsoft Foundry plugin
Use Microsoft Foundry deployments from OpenClaw with API-key auth or Microsoft
Entra ID through the Azure CLI. The plugin owns Microsoft Foundry model
discovery, runtime token refresh, and MAI image generation.
Adds Microsoft Foundry model provider support to OpenClaw.
## Distribution
@@ -18,7 +16,10 @@ discovery, runtime token refresh, and MAI image generation.
## Surface
- Model provider: `microsoft-foundry`
providers: microsoft-foundry; contracts: imageGenerationProviders
<!-- openclaw-plugin-reference:manual-start -->
- Image-generation provider: `microsoft-foundry`
## Requirements
@@ -108,3 +109,5 @@ MAI image constraints:
Foundry deployment through onboarding or add `models.providers.microsoft-foundry.baseUrl`.
- `supports MAI image deployments only`: the selected image model points at a
non-MAI deployment. Use a deployed MAI image model for `image_generate`.
<!-- openclaw-plugin-reference:manual-end -->

View File

@@ -38,6 +38,19 @@ Choose your preferred auth method and follow the setup steps.
export AWS_REGION="us-west-2"
```
</Step>
<Step title="Opt in to provider data sharing for Claude Fable 5">
Claude Fable 5 and Claude Mythos-class Bedrock models require the Mantle Data Retention API mode `provider_data_share` before invocation. This opt-in allows Bedrock to share prompts and completions with Anthropic and retain them for up to 30 days for trust and safety review.
```bash
AWS_REGION="${AWS_REGION:-us-east-1}"
curl -X PUT "https://bedrock-mantle.${AWS_REGION}.api.aws/v1/data_retention" \
-H "Authorization: Bearer $AWS_BEARER_TOKEN_BEDROCK" \
-H "Content-Type: application/json" \
-d '{ "mode": "provider_data_share" }'
```
Use another Bedrock model in the config if you cannot accept that retention mode.
</Step>
<Step title="Verify models are discovered">
```bash
openclaw models list

View File

@@ -22,6 +22,7 @@ Moonshot and Kimi Coding are **separate providers**. Keys are not interchangeabl
| Model ref | Name | Reasoning | Input | Context | Max output |
| --------------------------------- | ---------------------- | --------- | ----------- | ------- | ---------- |
| `moonshot/kimi-k2.6` | Kimi K2.6 | No | text, image | 262,144 | 262,144 |
| `moonshot/kimi-k2.7-code` | Kimi K2.7 Code | Always on | text, image | 262,144 | 262,144 |
| `moonshot/kimi-k2.5` | Kimi K2.5 | No | text, image | 262,144 | 262,144 |
| `moonshot/kimi-k2-thinking` | Kimi K2 Thinking | Yes | text | 262,144 | 262,144 |
| `moonshot/kimi-k2-thinking-turbo` | Kimi K2 Thinking Turbo | Yes | text | 262,144 | 262,144 |
@@ -30,11 +31,18 @@ Moonshot and Kimi Coding are **separate providers**. Keys are not interchangeabl
[//]: # "moonshot-kimi-k2-ids:end"
Bundled cost estimates for current Moonshot-hosted K2 models use Moonshot's
published pay-as-you-go rates: Kimi K2.6 is $0.16/MTok cache hit,
published pay-as-you-go rates: Kimi K2.7 Code is $0.19/MTok cache hit,
$0.95/MTok input, and $4.00/MTok output; Kimi K2.6 is $0.16/MTok cache hit,
$0.95/MTok input, and $4.00/MTok output; Kimi K2.5 is $0.10/MTok cache hit,
$0.60/MTok input, and $3.00/MTok output. Other legacy catalog entries keep
zero-cost placeholders unless you override them in config.
Kimi K2.7 Code always uses native thinking. OpenClaw exposes only the `on`
thinking state for this model and omits outbound `thinking` and
`reasoning_effort` controls, as required by Moonshot. OpenClaw also omits
sampling overrides that K2.7 fixes to provider defaults. Kimi K2.6 remains the
onboarding default.
## Getting started
Choose your provider and follow the setup steps.
@@ -109,6 +117,7 @@ Choose your provider and follow the setup steps.
models: {
// moonshot-kimi-k2-aliases:start
"moonshot/kimi-k2.6": { alias: "Kimi K2.6" },
"moonshot/kimi-k2.7-code": { alias: "Kimi K2.7 Code" },
"moonshot/kimi-k2.5": { alias: "Kimi K2.5" },
"moonshot/kimi-k2-thinking": { alias: "Kimi K2 Thinking" },
"moonshot/kimi-k2-thinking-turbo": { alias: "Kimi K2 Thinking Turbo" },
@@ -135,6 +144,15 @@ Choose your provider and follow the setup steps.
contextWindow: 262144,
maxTokens: 262144,
},
{
id: "kimi-k2.7-code",
name: "Kimi K2.7 Code",
reasoning: true,
input: ["text", "image"],
cost: { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 262144,
},
{
id: "kimi-k2.5",
name: "Kimi K2.5",
@@ -288,7 +306,13 @@ Config lives under `plugins.entries.moonshot.config.webSearch`:
<AccordionGroup>
<Accordion title="Native thinking mode">
Moonshot Kimi supports binary native thinking:
Kimi K2.7 Code always uses native thinking. Moonshot requires clients to
omit the `thinking` field for this model, so OpenClaw exposes only `on` and
ignores stale `off` settings. K2.7 also fixes `temperature`, `top_p`, `n`,
`presence_penalty`, and `frequency_penalty`; OpenClaw omits configured
overrides for those fields.
Other Moonshot Kimi models support binary native thinking:
- `thinking: { type: "enabled" }`
- `thinking: { type: "disabled" }`
@@ -311,7 +335,7 @@ Config lives under `plugins.entries.moonshot.config.webSearch`:
}
```
OpenClaw also maps runtime `/think` levels for Moonshot:
OpenClaw maps runtime `/think` levels for those models:
| `/think` level | Moonshot behavior |
| -------------------- | -------------------------- |
@@ -319,14 +343,16 @@ Config lives under `plugins.entries.moonshot.config.webSearch`:
| Any non-off level | `thinking.type=enabled` |
<Warning>
When Moonshot thinking is enabled, `tool_choice` must be `auto` or `none`. OpenClaw normalizes incompatible `tool_choice` values to `auto` for compatibility.
When Moonshot thinking is enabled, `tool_choice` must be `auto` or `none`. OpenClaw normalizes incompatible values to `auto`. This includes Kimi K2.7 Code, whose thinking mode cannot be disabled to preserve a pinned tool choice.
</Warning>
Kimi K2.6 also accepts an optional `thinking.keep` field that controls
multi-turn retention of `reasoning_content`. Set it to `"all"` to keep full
reasoning across turns; omit it (or leave it `null`) to use the server
default strategy. OpenClaw only forwards `thinking.keep` for
`moonshot/kimi-k2.6` and strips it from other models.
`moonshot/kimi-k2.6` and strips it from other models. Kimi K2.7 Code
preserves full reasoning history by default while OpenClaw omits the entire
`thinking` field.
```json5
{
@@ -347,7 +373,7 @@ Config lives under `plugins.entries.moonshot.config.webSearch`:
</Accordion>
<Accordion title="Tool call id sanitization">
Moonshot Kimi serves tool_call ids shaped like `functions.<name>:<index>`. OpenClaw preserves them unchanged so multi-turn tool calls keep working.
Moonshot Kimi serves native tool_call ids shaped like `functions.<name>:<index>`. For the OpenAI-completions transport, OpenClaw preserves the first occurrence of each native Kimi id and rewrites later duplicates to deterministic OpenAI-style `call_*` ids. Matching tool results are remapped with the same id so replay remains unique without stripping Kimi's first native id.
To force strict sanitization on a custom OpenAI-compatible provider, set `sanitizeToolCallIds: true`:

View File

@@ -115,7 +115,7 @@ Configuration location:
- `safeBins` comes from config (`tools.exec.safeBins` or per-agent `agents.list[].tools.exec.safeBins`).
- `safeBinTrustedDirs` comes from config (`tools.exec.safeBinTrustedDirs` or per-agent `agents.list[].tools.exec.safeBinTrustedDirs`).
- `safeBinProfiles` comes from config (`tools.exec.safeBinProfiles` or per-agent `agents.list[].tools.exec.safeBinProfiles`). Per-agent profile keys override global keys.
- allowlist entries live in host-local `~/.openclaw/exec-approvals.json` under `agents.<id>.allowlist` (or via Control UI / `openclaw approvals allowlist ...`).
- allowlist entries live in the host-local approvals file under `agents.<id>.allowlist` (or via Control UI / `openclaw approvals allowlist ...`).
- `openclaw security audit` warns with `tools.exec.safe_bins_interpreter_unprofiled` when interpreter/runtime bins appear in `safeBins` without explicit profiles.
- `openclaw doctor --fix` can scaffold missing custom `safeBinProfiles.<bin>` entries as `{}` (review and tighten afterward). Interpreter/runtime bins are not auto-scaffolded.

View File

@@ -23,7 +23,7 @@ Codex Guardian mapping, and ACPX harness permissions, see
Effective policy is the **stricter** of `tools.exec.*` and approvals
defaults; if an approvals field is omitted, the `tools.exec` value is
used. Host exec also uses local approvals state on that machine - a
host-local `ask: "always"` in `~/.openclaw/exec-approvals.json` keeps
host-local `ask: "always"` in the execution host approvals file keeps
prompting even if session or config defaults request `ask: "on-miss"`.
</Note>
@@ -73,12 +73,20 @@ Exec approvals are enforced locally on the execution host:
## Settings and storage
Approvals live in a local JSON file on the execution host:
Approvals live in a local JSON file on the execution host. When
`OPENCLAW_STATE_DIR` is set, the file follows that state directory;
otherwise it uses the default OpenClaw state directory:
```text
$OPENCLAW_STATE_DIR/exec-approvals.json
# otherwise
~/.openclaw/exec-approvals.json
```
The default approval socket follows the same root:
`$OPENCLAW_STATE_DIR/exec-approvals.sock`, or
`~/.openclaw/exec-approvals.sock` when the variable is unset.
Example schema:
```json
@@ -210,7 +218,7 @@ agent under `agents.list[].tools.exec.commandHighlighting`.
If you want host exec to run without approval prompts, you must open
**both** policy layers - requested exec policy in OpenClaw config
(`tools.exec.*`) **and** host-local approvals policy in
`~/.openclaw/exec-approvals.json`.
the execution host approvals file.
OpenClaw defaults omitted `askFallback` to `deny`. Set host
`askFallback` to `full` explicitly when a no-UI approval prompt should
@@ -281,8 +289,7 @@ openclaw exec-policy preset yolo
That local shortcut updates both:
- Local `tools.exec.host/security/ask`.
- Local `~/.openclaw/exec-approvals.json` defaults, including
`askFallback: "full"`.
- Local approvals file defaults, including `askFallback: "full"`.
It is intentionally local-only. To change gateway-host or node-host
approvals remotely, use `openclaw approvals set --gateway` or
@@ -425,7 +432,7 @@ shows last-used metadata per pattern so you can keep the list tidy.
The target selector chooses **Gateway** (local approvals) or a **Node**.
Nodes must advertise `system.execApprovals.get/set` (macOS app or
headless node host). If a node does not advertise exec approvals yet,
edit its local `~/.openclaw/exec-approvals.json` directly.
edit its local approvals file directly.
CLI: `openclaw approvals` supports gateway or node editing - see
[Approvals CLI](/cli/approvals).

View File

@@ -47,7 +47,7 @@ Where to execute. `auto` resolves to `sandbox` when a sandbox runtime is active
<ParamField path="security" type="'deny' | 'allowlist' | 'full'">
Ignored for normal tool calls. `gateway` / `node` security is controlled by
`tools.exec.security` and `~/.openclaw/exec-approvals.json`; elevated mode can
`tools.exec.security` and the host approvals file; elevated mode can
force `security=full` only when the operator explicitly grants elevated access.
</ParamField>
@@ -75,7 +75,7 @@ Notes:
- `tools.exec.mode` is the normalized policy knob. Values are `deny`, `allowlist`, `ask`, `auto`, and `full`. `auto` runs deterministic allowlist/safe-bin matches directly and routes every remaining exec approval case through OpenClaw's native auto reviewer before asking a human. `ask` / `ask=always` still asks a human every time.
- With no extra config, `host=auto` still "just works": no sandbox means it resolves to `gateway`; a live sandbox means it stays in the sandbox.
- `elevated` escapes the sandbox onto the configured host path: `gateway` by default, or `node` when `tools.exec.host=node` (or the session default is `host=node`). It is only available when elevated access is enabled for the current session/provider.
- `gateway`/`node` approvals are controlled by `~/.openclaw/exec-approvals.json`.
- `gateway`/`node` approvals are controlled by the host approvals file.
- `node` requires a paired node (companion app or headless node host).
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
- `exec host=node` is the only shell-execution path for nodes; the legacy `nodes.run` wrapper has been removed.
@@ -114,7 +114,7 @@ Notes:
- `tools.exec.host` (default: `auto`; resolves to `sandbox` when sandbox runtime is active, `gateway` otherwise)
- `tools.exec.security` (default: `deny` for sandbox, `full` for gateway + node when unset)
- `tools.exec.ask` (default: `off`)
- No-approval host exec is the default for gateway + node. If you want approvals/allowlist behavior, tighten both `tools.exec.*` and the host `~/.openclaw/exec-approvals.json`; see [Exec approvals](/tools/exec-approvals#yolo-mode-no-approval).
- No-approval host exec is the default for gateway + node. If you want approvals/allowlist behavior, tighten both `tools.exec.*` and the host approvals file; see [Exec approvals](/tools/exec-approvals#yolo-mode-no-approval).
- YOLO comes from the host-policy defaults (`security=full`, `ask=off`), not from `host=auto`. If you want to force gateway or node routing, set `tools.exec.host` or use `/exec host=...`.
- In `security=full` plus `ask=off` mode, host exec follows the configured policy directly; there is no extra heuristic command-obfuscation prefilter or script-preflight rejection layer.
- `tools.exec.node` (default: unset)

View File

@@ -190,6 +190,7 @@ agent session or the CLI.
autonomous: {
enabled: false,
},
allowSymlinkTargetWrites: false,
approvalPolicy: "pending",
maxPending: 50,
maxSkillBytes: 40000,
@@ -200,6 +201,9 @@ agent session or the CLI.
- `autonomous.enabled`: allows OpenClaw to create pending proposals from durable
conversation signals after successful turns. Default: `false`.
- `allowSymlinkTargetWrites`: allows apply to write through workspace skill
symlinks whose real target is listed in `skills.load.allowSymlinkTargets`.
Default: `false`.
- `approvalPolicy: "pending"`: requires an approval prompt before
agent-initiated `apply`, `reject`, or `quarantine`.
- `approvalPolicy: "auto"`: skips that approval prompt. The agent must still
@@ -265,6 +269,7 @@ Default state directory: `~/.openclaw`.
| `Skill proposal content is too large` | Shorten the proposal body or raise `skills.workshop.maxSkillBytes`. |
| `Target skill changed after proposal creation` | Revise the proposal against the current target, or create a new proposal. |
| `Proposal scan failed` | Inspect scanner findings, then revise or quarantine the proposal. |
| `untrusted symlink target` | Configure `skills.load.allowSymlinkTargets` and enable `skills.workshop.allowSymlinkTargetWrites` only for intentional shared skill roots. |
| `Support file paths must be under one of...` | Move support files under `assets/`, `examples/`, `references/`, `scripts/`, or `templates/`. |
| Proposal does not show in list | Check the selected `--agent` workspace and `OPENCLAW_STATE_DIR`. |
| Agent cannot call `skill_workshop` | Check the active tool policy and run mode. `coding` includes the tool; restrictive `tools.allow` policies must list it explicitly, and sandboxed runs must use a normal host-side agent session or the CLI. |

View File

@@ -29,6 +29,7 @@ Most skills configuration lives under `skills` in
},
workshop: {
autonomous: { enabled: false },
allowSymlinkTargetWrites: false,
approvalPolicy: "pending",
maxPending: 50,
maxSkillBytes: 40000,
@@ -333,6 +334,13 @@ different visible skill set per agent.
quarantine. `auto` allows those actions without approval.
</ParamField>
<ParamField path="skills.workshop.allowSymlinkTargetWrites" type="boolean" default="false">
Allow Skill Workshop apply to write through workspace skill symlinks whose
real target is already trusted by `skills.load.allowSymlinkTargets`. Keep this
disabled unless generated proposal applies should mutate that shared skill
root.
</ParamField>
<ParamField path="skills.workshop.maxPending" type="number" default="50">
Maximum pending and quarantined proposals retained per workspace.
</ParamField>
@@ -365,6 +373,23 @@ With this config, `<workspace>/skills/manager -> ~/Projects/manager/skills` is
accepted after realpath resolution. `extraDirs` scans the sibling repo directly;
`allowSymlinkTargets` preserves the symlinked path for existing layouts.
Skill Workshop apply does not write through those symlinks by default. To let
Workshop apply mutate skills under already-trusted symlink targets, opt in
separately:
```json5
{
skills: {
load: {
allowSymlinkTargets: ["~/Projects/manager/skills"],
},
workshop: {
allowSymlinkTargetWrites: true,
},
},
}
```
Managed `~/.openclaw/skills` and personal `~/.agents/skills` directories
already accept skill-directory symlinks (per-skill `SKILL.md` containment still
applies).

View File

@@ -204,6 +204,8 @@ publish and sync.
Workspace, project-agent, and extra-dir skill discovery only accepts skill
roots whose resolved realpath stays inside the configured root, unless
`skills.load.allowSymlinkTargets` explicitly trusts a target root.
Skill Workshop writes through those trusted targets only when
`skills.workshop.allowSymlinkTargetWrites` is enabled.
Managed `~/.openclaw/skills` and personal `~/.agents/skills` may contain
symlinked skill folders, but every `SKILL.md` realpath must still stay
inside its resolved skill directory.
@@ -533,6 +535,8 @@ aligned.
Use `allowSymlinkTargets` for intentional symlinked layouts where a skill
root symlink points outside the configured root, for example
`<workspace>/skills/manager -> ~/Projects/manager/skills`.
Enable `skills.workshop.allowSymlinkTargetWrites` only when Skill Workshop
should also apply proposals through those trusted symlinked paths.
</Accordion>
<Accordion title="Remote macOS nodes (Linux gateway)">

View File

@@ -35,7 +35,7 @@ title: "Thinking levels"
- Google Gemini maps `/think adaptive` to Gemini's provider-owned dynamic thinking. Gemini 3 requests omit a fixed `thinkingLevel`, while Gemini 2.5 requests send `thinkingBudget: -1`; fixed levels still map to the closest Gemini `thinkingLevel` or budget for that model family.
- MiniMax M2.x (`minimax/MiniMax-M2*`) on the Anthropic-compatible streaming path defaults to `thinking: { type: "disabled" }` unless you explicitly set thinking in model params or request params. This avoids leaked `reasoning_content` deltas from M2.x's non-native Anthropic stream format. MiniMax-M3 (and M3.x) is exempt: M3 emits proper Anthropic thinking blocks and returns empty content when thinking is disabled, so OpenClaw keeps M3 on the provider's omitted/adaptive thinking path.
- Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`).
- Moonshot (`moonshot/*`) maps `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`.
- Moonshot Kimi K2.7 Code (`moonshot/kimi-k2.7-code`) always thinks. Its profile exposes only `on`, and OpenClaw omits the outbound `thinking` field as required by Moonshot. Other `moonshot/*` models map `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`.
## Resolution order

View File

@@ -74,7 +74,7 @@ The same browser-local pattern applies to the assistant avatar override. Uploade
## Runtime config endpoint
The Control UI fetches its runtime settings from `/__openclaw/control-ui-config.json`. That endpoint is gated by the same gateway auth as the rest of the HTTP surface: unauthenticated browsers cannot fetch it, and a successful fetch requires either an already valid gateway token/password, Tailscale Serve identity, or a trusted-proxy identity.
The Control UI fetches its runtime settings from `/control-ui-config.json`, resolved relative to the gateway's Control UI base path (for example `/__openclaw__/control-ui-config.json` when the UI is served under `/__openclaw__/`). That endpoint is gated by the same gateway auth as the rest of the HTTP surface: unauthenticated browsers cannot fetch it, and a successful fetch requires either an already valid gateway token/password, Tailscale Serve identity, or a trusted-proxy identity.
## Language support

View File

@@ -224,9 +224,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -240,9 +240,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -256,9 +256,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -272,9 +272,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -288,9 +288,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -304,9 +304,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -320,9 +320,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -336,9 +336,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -352,9 +352,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -368,9 +368,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -384,9 +384,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -400,9 +400,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -416,9 +416,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -432,9 +432,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -448,9 +448,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -464,9 +464,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -480,9 +480,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -496,9 +496,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -512,9 +512,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -528,9 +528,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -544,9 +544,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -560,9 +560,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@@ -576,9 +576,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -592,9 +592,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -608,9 +608,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -624,9 +624,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@@ -1208,9 +1208,9 @@
}
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -1220,32 +1220,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escape-html": {

View File

@@ -477,11 +477,21 @@ describe("bedrock mantle discovery", () => {
expect(provider?.api).toBe("openai-completions");
expect(provider?.auth).toBe("api-key");
expect(provider?.apiKey).toBe("env:AWS_BEARER_TOKEN_BEDROCK");
expect(provider?.models).toHaveLength(2);
expect(provider?.models).toHaveLength(3);
const opus = provider?.models?.find((model) => model.id === "anthropic.claude-opus-4-7");
expect(opus?.api).toBe("anthropic-messages");
expect(opus?.reasoning).toBe(false);
expect(opus).not.toHaveProperty("baseUrl");
const mythos = provider?.models?.find(
(model) => model.id === "anthropic.claude-mythos-preview",
);
expect(mythos).toMatchObject({
api: "anthropic-messages",
reasoning: true,
params: { canonicalModelId: "claude-mythos-preview" },
contextWindow: 1_000_000,
maxTokens: 128_000,
});
});
it("returns null when no auth is available", async () => {

View File

@@ -404,6 +404,17 @@ export async function resolveImplicitMantleProvider(params: {
contextWindow: 1_000_000,
maxTokens: 128_000,
},
{
id: "anthropic.claude-mythos-preview",
name: "Claude Mythos Preview",
api: "anthropic-messages" as const,
reasoning: true,
params: { canonicalModelId: "claude-mythos-preview" },
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_000_000,
maxTokens: 128_000,
},
];
const allModels = [...models, ...claudeModels];

View File

@@ -6,7 +6,7 @@ import {
resolveMantleAnthropicBaseUrl,
} from "./mantle-anthropic.runtime.js";
function createTestModel(): Model {
function createTestModel(overrides: Partial<Model> = {}): Model {
return {
id: "anthropic.claude-opus-4-7",
name: "Claude Opus 4.7",
@@ -21,6 +21,7 @@ function createTestModel(): Model {
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 1_000_000,
maxTokens: 128_000,
...overrides,
} as Model;
}
@@ -112,6 +113,69 @@ describe("createMantleAnthropicStreamFn", () => {
expect(streamOptions.thinkingEnabled).toBe(false);
});
it("defaults Mythos Preview to adaptive high effort", () => {
const model = createTestModel({
id: "anthropic.claude-mythos-preview",
name: "Claude Mythos Preview",
reasoning: true,
params: { canonicalModelId: "claude-mythos-preview" },
});
const context = { messages: [] };
const deps = createTestDeps();
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
void createMantleAnthropicStreamFn(deps)(model, context, {
apiKey: "bedrock-bearer-token",
});
expectFirstStreamCall(deps, model, context);
const streamOptions = firstStreamOptions(deps);
expect(streamOptions.thinkingEnabled).toBe(true);
expect(streamOptions.effort).toBe("high");
});
it("clamps unsupported Mythos Preview max effort to high", () => {
const model = createTestModel({
id: "anthropic.claude-mythos-preview",
name: "Claude Mythos Preview",
reasoning: true,
params: { canonicalModelId: "claude-mythos-preview" },
});
const context = { messages: [] };
const deps = createTestDeps();
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
void createMantleAnthropicStreamFn(deps)(model, context, {
apiKey: "bedrock-bearer-token",
reasoning: "max",
});
expectFirstStreamCall(deps, model, context);
const streamOptions = firstStreamOptions(deps);
expect(streamOptions.thinkingEnabled).toBe(true);
expect(streamOptions.effort).toBe("high");
});
it("maps Mythos Preview minimal reasoning to low effort", () => {
const model = createTestModel({
id: "anthropic.claude-mythos-preview",
name: "Claude Mythos Preview",
reasoning: true,
params: { canonicalModelId: "claude-mythos-preview" },
});
const deps = createTestDeps();
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
void createMantleAnthropicStreamFn(deps)(model, { messages: [] }, {
apiKey: "bedrock-bearer-token",
reasoning: "minimal",
});
const streamOptions = firstStreamOptions(deps);
expect(streamOptions.thinkingEnabled).toBe(true);
expect(streamOptions.effort).toBe("low");
});
it("normalizes Mantle provider URLs to the Anthropic endpoint", () => {
expect(resolveMantleAnthropicBaseUrl("https://bedrock-mantle.us-east-1.api.aws/v1")).toBe(
"https://bedrock-mantle.us-east-1.api.aws/anthropic",

View File

@@ -27,6 +27,36 @@ function requiresDefaultSampling(modelId: string): boolean {
return modelId.includes("claude-opus-4-7");
}
function isClaudeMythosPreviewModel(model: Model): boolean {
return [model.id, model.name, model.params?.canonicalModelId]
.filter((value): value is string => typeof value === "string")
.some((value) =>
/(?:^|-)claude-mythos-preview(?=$|[^a-z0-9])/.test(
value
.trim()
.toLowerCase()
.replace(/[\s_.:]+/g, "-"),
),
);
}
function resolveMantleReasoning(
model: Model,
options: SimpleStreamOptions | undefined,
): NonNullable<SimpleStreamOptions["reasoning"]> | undefined {
if (requiresDefaultSampling(model.id)) {
return undefined;
}
const reasoning = options?.reasoning ?? (isClaudeMythosPreviewModel(model) ? "high" : undefined);
if (!isClaudeMythosPreviewModel(model)) {
return reasoning;
}
if (reasoning === "minimal") {
return "low";
}
return reasoning === "xhigh" || reasoning === "max" ? "high" : reasoning;
}
function mergeHeaders(
...headerSources: Array<Record<string, string> | undefined>
): Record<string, string> {
@@ -109,7 +139,8 @@ export function createMantleAnthropicStreamFn(deps?: {
// Plugin package deps can give this plugin a distinct physical SDK copy.
// The client API is the same, but the SDK class private field makes types nominal.
const streamClient = client as unknown as AnthropicStreamClient;
if (!options?.reasoning || requiresDefaultSampling(model.id)) {
const reasoning = resolveMantleReasoning(model, options);
if (!reasoning) {
return streamFn(model as Model<"anthropic-messages">, context, {
...base,
client: streamClient,
@@ -120,14 +151,15 @@ export function createMantleAnthropicStreamFn(deps?: {
const adjusted = adjustMaxTokensForThinking(
base.maxTokens || 0,
model.maxTokens,
options.reasoning,
options.thinkingBudgets,
reasoning,
options?.thinkingBudgets,
);
return streamFn(model as Model<"anthropic-messages">, context, {
...base,
client: streamClient,
maxTokens: adjusted.maxTokens,
thinkingEnabled: true,
...(isClaudeMythosPreviewModel(model) ? { effort: reasoning } : {}),
thinkingBudgetTokens: adjusted.thinkingBudget,
});
};

View File

@@ -166,6 +166,65 @@ describe("bedrock discovery", () => {
});
});
it("marks known Fable inference profile fallbacks as reasoning capable", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "us.anthropic.claude-fable-5",
inferenceProfileName: "US Claude Fable 5",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-fable-5",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
expect(models).toHaveLength(1);
expectModelFields(models[0], {
id: "us.anthropic.claude-fable-5",
reasoning: true,
contextWindow: 1_000_000,
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: "max" },
});
});
it("skips Mythos Preview inference profiles because Mantle owns that route", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "us.anthropic.claude-mythos-preview",
inferenceProfileName: "US Claude Mythos Preview",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn:
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-mythos-preview",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
expect(models).toEqual([]);
});
it("normalizes region-prefixed versioned model ids when resolving context windows", async () => {
sendMock
.mockResolvedValueOnce({

View File

@@ -157,6 +157,13 @@ function resolveKnownContextWindow(modelId: string): number | undefined {
return undefined;
}
function isKnownClaudeMythosPreviewModelId(modelId: string): boolean {
const stripped = modelId.replace(/^(?:us|eu|ap|apac|au|jp|global)\./, "");
return [modelId, stripped].some((candidate) =>
/(?:^|[/.:])anthropic\.claude-mythos-preview(?:$|[-.:/])/i.test(candidate),
);
}
function resolveKnownThinkingLevelMap(
modelId: string,
): ModelDefinitionConfig["thinkingLevelMap"] | undefined {
@@ -322,6 +329,9 @@ function shouldIncludeSummary(summary: BedrockModelSummary, filter: string[]): b
if (summary.responseStreamingSupported !== true) {
return false;
}
if (isKnownClaudeMythosPreviewModelId(summary.modelId)) {
return false;
}
if (!includesTextModalities(summary.outputModalities)) {
return false;
}
@@ -454,6 +464,9 @@ function resolveInferenceProfiles(
// Look up the underlying foundation model to inherit its capabilities.
const baseModelId = resolveBaseModelId(profile);
if (isKnownClaudeMythosPreviewModelId(baseModelId ?? profile.inferenceProfileId)) {
continue;
}
const baseModel = baseModelId
? foundationModels.get(normalizeLowercaseStringOrEmpty(baseModelId))
: undefined;

View File

@@ -835,6 +835,44 @@ describe("amazon-bedrock provider plugin", () => {
expect(payload.inferenceConfig).toEqual({});
});
it("does not re-upgrade Mythos Preview max thinking in the final payload", async () => {
const provider = await registerSingleProviderPlugin(amazonBedrockPlugin);
const wrapped = provider.wrapStreamFn?.({
provider: "amazon-bedrock",
modelId: "us.anthropic.claude-mythos-preview",
streamFn: spyStreamFn,
thinkingLevel: "max",
} as never);
const result = wrapped?.(
{
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
id: "us.anthropic.claude-mythos-preview",
name: "Claude Mythos Preview",
reasoning: true,
} as never,
{ messages: [] } as never,
{ reasoning: "max" } as never,
) as Record<string, unknown> | undefined;
const payload = {
inferenceConfig: { temperature: 0.2 },
additionalModelRequestFields: {
thinking: { type: "adaptive" },
output_config: { effort: "high" },
},
};
await (result?.onPayload as ((p: Record<string, unknown>) => unknown) | undefined)?.(payload);
expect(payload.additionalModelRequestFields).toEqual({
thinking: { type: "adaptive" },
output_config: { effort: "high" },
});
expect(payload.inferenceConfig).toEqual({});
});
it("classifies nested Bedrock deprecated-temperature validation as format failover", async () => {
const provider = await registerSingleProviderPlugin(amazonBedrockPlugin);

View File

@@ -23,6 +23,7 @@ import { mergeImplicitBedrockProvider, resolveBedrockConfigApiKey } from "./disc
import { bedrockMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
import { streamBedrock, streamSimpleBedrock } from "./stream.runtime.js";
import {
isLatestAdaptiveBedrockModelRef,
isOpus47OrNewerBedrockModelRef,
resolveBedrockNativeThinkingLevelMap,
resolveBedrockClaudeThinkingProfile,
@@ -596,8 +597,10 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
currentPluginConfig?.discovery?.region;
const mayNeedCacheInjection =
isBedrockAppInferenceProfile(modelId) && !sharedRuntimeWouldInjectCachePoints(modelId);
const shouldOmitTemperature = opus47OrNewer || fable5;
const shouldOmitTemperature =
opus47OrNewer || fable5 || isLatestAdaptiveBedrockModelRef(modelId, model?.params);
const shouldPatchMaxThinking = supportsNativeMax && thinkingLevel === "max";
const shouldPatchPayload = shouldOmitTemperature || shouldPatchMaxThinking;
// For known Anthropic models (heuristic match), enable injection immediately.
// For opaque profile IDs, we'll resolve via GetInferenceProfile on first call.
@@ -627,13 +630,17 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
context,
withAwsCredentialRefreshOnPayload({
...merged,
...(shouldPatchMaxThinking
...(shouldPatchPayload
? {
onPayload: (payload: unknown, payloadModel: unknown) => {
if (payload && typeof payload === "object") {
const payloadRecord = payload as Record<string, unknown>;
patchMaxThinkingEffort(payloadRecord);
omitUnsupportedClaudePayloadTemperature(payloadRecord);
if (shouldPatchMaxThinking) {
patchMaxThinkingEffort(payloadRecord);
}
if (shouldOmitTemperature) {
omitUnsupportedClaudePayloadTemperature(payloadRecord);
}
}
return originalOnPayload?.(payload, payloadModel);
},

View File

@@ -167,7 +167,34 @@ describe("Bedrock profile endpoint resolution", () => {
});
describe("Bedrock thinking effort mapping", () => {
it("caps max effort at high for Claude Sonnet 4.6", () => {
it("does not force adaptive thinking for optional Claude models when callers omit reasoning", () => {
const model = bedrockModel({
id: "anthropic.claude-sonnet-4-6-v1:0",
name: "Claude Sonnet 4.6",
reasoning: true,
});
const options = testing.resolveSimpleBedrockOptions(model, {});
expect(options.reasoning).toBeUndefined();
expect(testing.buildAdditionalModelRequestFields(model, options)).toBeUndefined();
});
it("forces adaptive thinking for Bedrock Mythos Preview when callers omit reasoning", () => {
const model = bedrockModel({
id: "us.anthropic.claude-mythos-preview",
name: "US Claude Mythos Preview",
reasoning: true,
});
const options = testing.resolveSimpleBedrockOptions(model, {});
expect(options.reasoning).toBe("high");
expect(testing.buildAdditionalModelRequestFields(model, options)).toEqual({
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "high" },
});
});
it("clamps max effort for Claude models without native max support", () => {
expect(
testing.mapThinkingLevelToEffort(
bedrockModel({

View File

@@ -351,29 +351,38 @@ export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", Simp
model: Model<"bedrock-converse-stream">,
context: Context,
options?: SimpleStreamOptions,
) => {
) => streamBedrock(model, context, resolveSimpleBedrockOptions(model, options));
function resolveSimpleBedrockOptions(
model: Model<"bedrock-converse-stream">,
options?: SimpleStreamOptions,
): BedrockOptions {
const base = buildBaseOptions(model, options, undefined);
if (usesClaudeFable5BedrockContract(model)) {
return streamBedrock(model, context, {
return {
...base,
reasoning: options?.reasoning ?? "high",
thinkingBudgets: options?.thinkingBudgets,
} satisfies BedrockOptions);
} satisfies BedrockOptions;
}
if (!options?.reasoning) {
return streamBedrock(model, context, {
const reasoning =
isAnthropicClaudeModel(model) && requiresMandatoryAdaptiveThinking(model)
? "high"
: undefined;
return {
...base,
reasoning: undefined,
} satisfies BedrockOptions);
reasoning,
} satisfies BedrockOptions;
}
if (isAnthropicClaudeModel(model)) {
if (supportsAdaptiveThinking(model)) {
return streamBedrock(model, context, {
return {
...base,
reasoning: options.reasoning,
thinkingBudgets: options.thinkingBudgets,
} satisfies BedrockOptions);
} satisfies BedrockOptions;
}
// Undefined means the caller did not request an output cap; let the helper use the model cap.
@@ -385,7 +394,7 @@ export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", Simp
options.thinkingBudgets,
);
return streamBedrock(model, context, {
return {
...base,
maxTokens: adjusted.maxTokens,
reasoning: options.reasoning,
@@ -393,15 +402,15 @@ export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", Simp
...options.thinkingBudgets,
[clampReasoning(options.reasoning)!]: adjusted.thinkingBudget,
},
} satisfies BedrockOptions);
} satisfies BedrockOptions;
}
return streamBedrock(model, context, {
return {
...base,
reasoning: options.reasoning,
thinkingBudgets: options.thinkingBudgets,
} satisfies BedrockOptions);
};
} satisfies BedrockOptions;
}
function handleContentBlockStart(
event: ContentBlockStartEvent,
@@ -553,15 +562,37 @@ function resolveClaudeProfileNameModelId(modelName?: string): string | undefined
if (!normalized.includes("claude")) {
return undefined;
}
const family = /(?:fable-5|opus-4-(?:6|7|8)|sonnet-4-6)(?:$|-)/.exec(normalized)?.[0];
const family = /(?:fable-5|mythos-preview|opus-4-(?:6|7|8)|sonnet-4-6)(?:$|-)/.exec(
normalized,
)?.[0];
return family ? `claude-${family.replace(/-$/, "")}` : undefined;
}
function isClaudeMythosPreviewModelId(modelId?: string): boolean {
return /(?:^|-)claude-mythos-preview(?=$|[^a-z0-9])/.test(
modelId
?.trim()
.toLowerCase()
.replace(/[\s_.:]+/g, "-") ?? "",
);
}
/** Check canonical metadata and profile names for adaptive Claude support. */
function supportsAdaptiveThinking(model: Model<"bedrock-converse-stream">): boolean {
const profileModelId = resolveClaudeProfileNameModelId(model.name);
return (
supportsClaudeAdaptiveThinking(model) || supportsClaudeAdaptiveThinking({ id: profileModelId })
supportsClaudeAdaptiveThinking(model) ||
supportsClaudeAdaptiveThinking({ id: profileModelId }) ||
isClaudeMythosPreviewModelId(resolveClaudeModelIdentity(model)) ||
isClaudeMythosPreviewModelId(profileModelId)
);
}
function requiresMandatoryAdaptiveThinking(model: Model<"bedrock-converse-stream">): boolean {
const profileModelId = resolveClaudeProfileNameModelId(model.name);
return (
isClaudeMythosPreviewModelId(resolveClaudeModelIdentity(model)) ||
isClaudeMythosPreviewModelId(profileModelId)
);
}
@@ -1071,9 +1102,11 @@ function createImageBlock(mimeType: string, data: string) {
/** Test-only hooks for Bedrock runtime conversion and endpoint policy. */
export const testing = {
buildAdditionalModelRequestFields,
convertMessages,
getConfiguredBedrockRegion,
hasConfiguredBedrockProfile,
mapThinkingLevelToEffort,
resolveSimpleBedrockOptions,
shouldUseExplicitBedrockEndpoint,
};

View File

@@ -43,6 +43,28 @@ export function isOpus47OrNewerBedrockModelRef(modelRef: string): boolean {
return isOpus47BedrockModelRef(modelRef) || isOpus48BedrockModelRef(modelRef);
}
function isMythosPreviewBedrockModelRef(modelRef: string): boolean {
return /(?:^|[/.:])(?:(?:us|eu|ap|apac|au|jp|global)\.)?(?:anthropic\.)?claude-mythos-preview(?:$|[-.:/])/i.test(
modelRef,
);
}
/** Return whether a Bedrock Claude ref needs latest adaptive-thinking request shaping. */
export function isLatestAdaptiveBedrockModelRef(
modelId: string,
params?: Record<string, unknown>,
): boolean {
const modelRef = { id: modelId, params };
const canonicalModelId = resolveClaudeModelIdentity(modelRef);
return (
resolveClaudeFable5ModelIdentity(modelRef) !== undefined ||
[modelId, canonicalModelId].some(
(candidate) =>
isOpus47OrNewerBedrockModelRef(candidate) || isMythosPreviewBedrockModelRef(candidate),
)
);
}
/** Return whether a Bedrock Claude ref supports max effort. */
export function supportsBedrockNativeMaxEffort(
modelId: string,
@@ -109,6 +131,12 @@ export function resolveBedrockClaudeThinkingProfile(
defaultLevel: "adaptive",
};
}
if (modelRefs.some(isMythosPreviewBedrockModelRef)) {
return {
levels: [...BASE_CLAUDE_THINKING_LEVELS, { id: "adaptive" }],
defaultLevel: "adaptive",
};
}
if (modelRefs.some((modelRef) => /claude-sonnet-4(?:\.|-)6(?:$|[-.])/i.test(modelRef))) {
return {
levels: [...BASE_CLAUDE_THINKING_LEVELS, { id: "adaptive" }],

View File

@@ -3,8 +3,6 @@ import { createAssistantMessageEventStream, type Model } from "openclaw/plugin-s
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
const SYSTEM_PROMPT_CACHE_BOUNDARY = "\n<!-- OPENCLAW_CACHE_BOUNDARY -->\n";
function createStreamDeps(): {
deps: AnthropicVertexStreamDeps;
streamAnthropicMock: ReturnType<typeof vi.fn>;
@@ -50,8 +48,6 @@ function makeModel(params: {
} as Model<"anthropic-messages">;
}
const CACHE_BOUNDARY_PROMPT = `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`;
type PayloadHook = (payload: unknown, payloadModel: unknown) => Promise<unknown>;
function streamAnthropicCall(streamAnthropicMock: ReturnType<typeof vi.fn>): unknown[] {
@@ -72,8 +68,8 @@ function streamTransportOptions(
return options as Record<string, unknown>;
}
function captureCacheBoundaryPayloadHook(
onPayload: PayloadHook,
function captureTransportPayloadHook(
onPayload: PayloadHook | undefined,
deps: AnthropicVertexStreamDeps,
streamAnthropicMock: ReturnType<typeof vi.fn>,
) {
@@ -82,14 +78,8 @@ function captureCacheBoundaryPayloadHook(
void streamFn(
model,
{
systemPrompt: CACHE_BOUNDARY_PROMPT,
messages: [{ role: "user", content: "Hello" }],
} as never,
{
cacheRetention: "short",
onPayload,
} as never,
{ messages: [{ role: "user", content: "Hello" }] } as never,
{ cacheRetention: "short", ...(onPayload ? { onPayload } : {}) } as never,
);
const transportOptions = streamTransportOptions(streamAnthropicMock);
@@ -97,26 +87,30 @@ function captureCacheBoundaryPayloadHook(
return { model, onPayload: transportOptions.onPayload as PayloadHook | undefined };
}
function buildExpectedCacheBoundaryPayload(messageText: string) {
// Mirrors the shared anthropic-messages transport output: cache boundary already
// split (uncached dynamic suffix) and all four cache_control markers allocated.
function buildBudgetedTransportPayload() {
return {
system: [
{
type: "text",
text: "Stable prefix",
cache_control: { type: "ephemeral" },
},
{
type: "text",
text: "Dynamic suffix",
},
{ type: "text", text: "Stable prefix", cache_control: { type: "ephemeral" } },
{ type: "text", text: "Dynamic suffix" },
],
tools: [
{ name: "exec", input_schema: { type: "object" }, cache_control: { type: "ephemeral" } },
],
messages: [
{
role: "user",
content: [{ type: "text", text: "Hello", cache_control: { type: "ephemeral" } }],
},
{ role: "assistant", content: [{ type: "tool_use", id: "t1", name: "exec", input: {} }] },
{
role: "user",
content: [
{
type: "text",
text: messageText,
type: "tool_result",
tool_use_id: "t1",
content: [],
cache_control: { type: "ephemeral" },
},
],
@@ -125,6 +119,29 @@ function buildExpectedCacheBoundaryPayload(messageText: string) {
};
}
function countCacheControlMarkers(payload: unknown): number {
let count = 0;
const visit = (value: unknown) => {
if (Array.isArray(value)) {
value.forEach(visit);
return;
}
if (!value || typeof value !== "object") {
return;
}
const record = value as Record<string, unknown>;
if (record.cache_control !== undefined) {
count += 1;
}
visit(record.content);
};
const record = payload as Record<string, unknown>;
visit(record.system);
visit(record.tools);
visit(record.messages);
return count;
}
describe("createAnthropicVertexStreamFn", () => {
beforeAll(async () => {
({ createAnthropicVertexStreamFn, createAnthropicVertexStreamFnForModel } =
@@ -180,7 +197,7 @@ describe("createAnthropicVertexStreamFn", () => {
expect(streamTransportOptions(streamAnthropicMock).maxTokens).toBe(128000);
});
it.each(["claude-opus-4-8", "claude-opus-4-7"])(
it.each(["claude-opus-4-8", "claude-opus-4-7", "claude-fable-5", "claude-mythos-5"])(
"omits unsupported temperature for %s",
(modelId) => {
const { deps, streamAnthropicMock } = createStreamDeps();
@@ -219,6 +236,21 @@ describe("createAnthropicVertexStreamFn", () => {
expect(streamTransportOptions(streamAnthropicMock)).not.toHaveProperty("temperature");
});
it("uses Mythos 5's mandatory adaptive Vertex contract by default", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-mythos-5", maxTokens: 128000 });
void streamFn(model, { messages: [] }, { temperature: 0.7 });
expect(streamTransportOptions(streamAnthropicMock)).toMatchObject({
thinkingEnabled: true,
effort: "high",
maxTokens: 128000,
});
expect(streamTransportOptions(streamAnthropicMock)).not.toHaveProperty("temperature");
});
it("uses canonical Claude policy for Vertex deployment aliases", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
@@ -328,63 +360,35 @@ describe("createAnthropicVertexStreamFn", () => {
expect(transportOptions).not.toHaveProperty("temperature");
});
it("applies Anthropic cache-boundary shaping before forwarding payload hooks", async () => {
it("keeps already-budgeted cache_control markers intact when forwarding payload hooks", async () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const onPayload = vi.fn(async (payload: unknown) => payload);
const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook(
const { model, onPayload: transportPayloadHook } = captureTransportPayloadHook(
onPayload,
deps,
streamAnthropicMock,
);
const payload = {
system: [
{
type: "text",
text: CACHE_BOUNDARY_PROMPT,
cache_control: { type: "ephemeral" },
},
],
messages: [{ role: "user", content: "Hello" }],
};
const payload = buildBudgetedTransportPayload();
const nextPayload = await transportPayloadHook?.(payload, model);
const expectedPayload = buildExpectedCacheBoundaryPayload("Hello");
expect(onPayload).toHaveBeenCalledWith(expectedPayload, model);
expect(nextPayload).toEqual(expectedPayload);
expect(onPayload).toHaveBeenCalledWith(payload, model);
expect(countCacheControlMarkers(nextPayload)).toBe(4);
expect((nextPayload as ReturnType<typeof buildBudgetedTransportPayload>).system[1]).toEqual({
type: "text",
text: "Dynamic suffix",
});
});
it("reapplies Anthropic cache-boundary shaping when payload hooks return a fresh payload", async () => {
it("omits the transport payload hook when the caller provides none", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const onPayload = vi.fn(async () => ({
system: [
{
type: "text",
text: CACHE_BOUNDARY_PROMPT,
},
],
messages: [{ role: "user", content: "Hello again" }],
}));
const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook(
onPayload,
const { onPayload: transportPayloadHook } = captureTransportPayloadHook(
undefined,
deps,
streamAnthropicMock,
);
const nextPayload = await transportPayloadHook?.(
{
system: [
{
type: "text",
text: CACHE_BOUNDARY_PROMPT,
},
],
messages: [{ role: "user", content: "Hello" }],
},
model,
);
expect(nextPayload).toEqual(buildExpectedCacheBoundaryPayload("Hello again"));
expect(transportPayloadHook).toBeUndefined();
});
it("omits maxTokens when neither the model nor request provide a finite limit", () => {

View File

@@ -1,6 +1,6 @@
/**
* Anthropic Vertex stream runtime. It constructs Vertex SDK clients and adapts
* OpenClaw stream options into Anthropic Messages payload policy.
* OpenClaw stream options for the shared Anthropic Messages transport.
*/
import { AnthropicVertex as AnthropicVertexSdk } from "@anthropic-ai/vertex-sdk";
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
@@ -18,10 +18,6 @@ import {
supportsClaudeNativeMaxEffort,
supportsClaudeNativeXhighEffort,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
applyAnthropicPayloadPolicyToParams,
resolveAnthropicPayloadPolicy,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId } from "./region.js";
type AnthropicVertexTransportOptions = ProviderStreamOptions & {
@@ -58,8 +54,12 @@ function isClaudeFable5Model(modelId: string): boolean {
return resolveClaudeFable5ModelIdentity({ id: modelId }) !== undefined;
}
function isClaudeMythos5Model(modelId: string): boolean {
return /(?:^|-)claude-mythos-5(?=$|[^a-z0-9])/.test(resolveClaudeModelIdentity({ id: modelId }));
}
function supportsAdaptiveThinking(modelId: string): boolean {
return supportsClaudeAdaptiveThinking({ id: modelId });
return supportsClaudeAdaptiveThinking({ id: modelId }) || isClaudeMythos5Model(modelId);
}
function mapAnthropicAdaptiveEffort(
@@ -82,10 +82,13 @@ function mapAnthropicAdaptiveEffort(
high: "high",
xhigh: isClaudeFable5Model(modelId)
? "xhigh"
: isClaudeOpus47OrNewerModel(modelId)
: isClaudeOpus47OrNewerModel(modelId) || isClaudeMythos5Model(modelId)
? "xhigh"
: "high",
max: supportsClaudeNativeMaxEffort({ id: modelId }) ? "max" : "high",
max:
supportsClaudeNativeMaxEffort({ id: modelId }) || isClaudeMythos5Model(modelId)
? "max"
: "high",
};
return effortMap[resolvedReasoning] ?? "high";
}
@@ -113,36 +116,6 @@ function resolveAnthropicVertexMaxTokens(params: {
return requested ?? modelMax;
}
function createAnthropicVertexOnPayload(params: {
model: { api: string; baseUrl?: string; provider: string };
cacheRetention: ProviderStreamOptions["cacheRetention"] | undefined;
onPayload: ProviderStreamOptions["onPayload"] | undefined;
}): NonNullable<ProviderStreamOptions["onPayload"]> {
const policy = resolveAnthropicPayloadPolicy({
provider: params.model.provider,
api: params.model.api,
baseUrl: params.model.baseUrl,
cacheRetention: params.cacheRetention,
enableCacheControl: true,
});
function applyPolicy(payload: unknown): unknown {
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
applyAnthropicPayloadPolicyToParams(payload as Record<string, unknown>, policy);
}
return payload;
}
return async (payload, model) => {
const shapedPayload = applyPolicy(payload);
const nextPayload = await params.onPayload?.(shapedPayload, model);
if (nextPayload === undefined || nextPayload === shapedPayload) {
return shapedPayload;
}
return applyPolicy(nextPayload);
};
}
/**
* Create a StreamFn that routes through OpenClaw's generic model stream with an
* injected `AnthropicVertex` client. All streaming, message conversion, and
@@ -173,11 +146,16 @@ export function createAnthropicVertexStreamFn(
});
const contractModelId = resolveClaudeModelIdentity(model);
const fable5 = isClaudeFable5Model(contractModelId);
const reasoning = options?.reasoning as ModelThinkingLevel | undefined;
const mandatoryAdaptiveThinking = fable5 || isClaudeMythos5Model(contractModelId);
const reasoning =
(options?.reasoning as ModelThinkingLevel | undefined) ??
(mandatoryAdaptiveThinking ? "high" : undefined);
const adaptiveThinking =
fable5 || Boolean(reasoning && supportsAdaptiveThinking(contractModelId));
mandatoryAdaptiveThinking || Boolean(reasoning && supportsAdaptiveThinking(contractModelId));
const temperature =
adaptiveThinking || isClaudeOpus47OrNewerModel(contractModelId)
adaptiveThinking ||
isClaudeOpus47OrNewerModel(contractModelId) ||
isClaudeMythos5Model(contractModelId)
? undefined
: options?.temperature;
const opts: AnthropicVertexTransportOptions = {
@@ -188,11 +166,10 @@ export function createAnthropicVertexStreamFn(
cacheRetention: options?.cacheRetention,
sessionId: options?.sessionId,
headers: options?.headers,
onPayload: createAnthropicVertexOnPayload({
model: transportModel,
cacheRetention: options?.cacheRetention,
onPayload: options?.onPayload,
}),
// The shared anthropic-messages transport already splits the system prompt
// cache boundary and budgets all cache_control markers; re-applying the
// payload policy here marked the uncached suffix and breached the 4-marker cap.
onPayload: options?.onPayload,
maxRetryDelayMs: options?.maxRetryDelayMs,
metadata: options?.metadata,
};

View File

@@ -710,6 +710,28 @@ describe("anthropic provider replay hooks", () => {
expect(resolved).toBeUndefined();
});
it("normalizes Claude Mythos Preview with native max but no xhigh thinking map", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const normalized = provider.normalizeResolvedModel?.({
provider: "anthropic",
modelId: "claude-mythos-preview",
model: {
id: "claude-mythos-preview",
name: "Claude Mythos Preview",
provider: "anthropic",
api: "anthropic-messages",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 64_000,
},
} as never);
expect(normalized?.thinkingLevelMap).toEqual({ max: "max" });
});
it("normalizes stale text-only modern Claude vision rows to image-capable", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);

View File

@@ -101,6 +101,28 @@
"contextWindow": 200000,
"maxTokens": 64000
},
{
"id": "claude-haiku-4-5",
"name": "Claude Haiku 4.5",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
},
"contextWindow": 200000,
"maxTokens": 64000
},
{
"id": "claude-haiku-4-5-20251001",
"name": "Claude Haiku 4.5",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
},
"contextWindow": 200000,
"maxTokens": 64000
},
{
"id": "claude-sonnet-4-6",
"name": "Claude Sonnet 4.6",

View File

@@ -0,0 +1,57 @@
// Anthropic tests cover provider manifest model catalog behavior.
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
type AnthropicManifest = {
modelCatalog?: {
providers?: {
anthropic?: {
models?: Array<{
id?: string;
name?: string;
reasoning?: boolean;
input?: string[];
mediaInput?: {
image?: {
maxSidePx?: number;
preferredSidePx?: number;
tokenMode?: string;
};
};
contextWindow?: number;
maxTokens?: number;
}>;
};
};
discovery?: Record<string, string>;
};
};
const manifest = JSON.parse(
readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"),
) as AnthropicManifest;
describe("Anthropic plugin manifest", () => {
it("resolves both official Claude Haiku 4.5 API identifiers from the static catalog", () => {
expect(manifest.modelCatalog?.discovery?.anthropic).toBe("static");
const models = manifest.modelCatalog?.providers?.anthropic?.models ?? [];
for (const id of ["claude-haiku-4-5", "claude-haiku-4-5-20251001"]) {
expect(models.find((model) => model.id === id)).toEqual({
id,
name: "Claude Haiku 4.5",
reasoning: true,
input: ["text", "image"],
mediaInput: {
image: {
maxSidePx: 1568,
preferredSidePx: 1568,
tokenMode: "provider",
},
},
contextWindow: 200000,
maxTokens: 64000,
});
}
});
});

View File

@@ -34,6 +34,7 @@ import {
resolveClaudeModelIdentity,
resolveClaudeThinkingProfile,
supportsClaudeAdaptiveThinking,
supportsClaudeNativeMaxEffort,
supportsClaudeNativeXhighEffort,
} from "openclaw/plugin-sdk/provider-model-shared";
import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
@@ -292,6 +293,11 @@ function buildAnthropicForwardCompatModel(
maxTokens: isAnthropic128kOutputModel(trimmedModelId)
? ANTHROPIC_MODERN_MAX_OUTPUT_TOKENS
: 64_000,
...(supportsClaudeNativeXhighEffort({ id: trimmedModelId })
? { thinkingLevelMap: { xhigh: "xhigh", max: "max" } }
: supportsAnthropicNativeMaxEffort(trimmedModelId)
? { thinkingLevelMap: { max: "max" } }
: {}),
};
}
@@ -361,6 +367,16 @@ function isAnthropicOpus47OrNewerModel(modelId: string): boolean {
return supportsClaudeNativeXhighEffort({ id: modelId }) && !isAnthropicFable5Model(modelId);
}
function isAnthropicMythosPreviewModel(modelId: string): boolean {
return /(?:^|-)claude-mythos-preview(?=$|[^a-z0-9])/.test(
resolveClaudeModelIdentity({ id: modelId }),
);
}
function supportsAnthropicNativeMaxEffort(modelId: string): boolean {
return supportsClaudeNativeMaxEffort({ id: modelId }) || isAnthropicMythosPreviewModel(modelId);
}
function hasConfiguredModelContextOverride(
config: ProviderNormalizeResolvedModelContext["config"],
provider: string,
@@ -455,15 +471,17 @@ function applyAnthropicThinkingLevelMap(params: {
}): ProviderRuntimeModel | undefined {
const fable5 = isAnthropicFable5Model(params.modelId);
const nativeXhigh = fable5 || isAnthropicOpus47OrNewerModel(params.modelId);
if (!matchesAnthropicModernModel(params.modelId)) {
if (!supportsAnthropicNativeMaxEffort(params.modelId)) {
return undefined;
}
const current = params.model.thinkingLevelMap;
const nativeDefaults = {
...(fable5 ? { off: "low" as const, minimal: "low" as const } : {}),
xhigh: nativeXhigh ? ("xhigh" as const) : null,
max: "max" as const,
};
const nativeDefaults = isAnthropicMythosPreviewModel(params.modelId)
? { max: "max" as const }
: {
...(fable5 ? { off: "low" as const, minimal: "low" as const } : {}),
xhigh: nativeXhigh ? ("xhigh" as const) : null,
max: "max" as const,
};
const currentEfforts = current as Record<string, string | null | undefined> | undefined;
if (Object.keys(nativeDefaults).every((level) => currentEfforts?.[level] !== undefined)) {
return undefined;
@@ -478,7 +496,7 @@ function applyAnthropicThinkingLevelMap(params: {
}
function matchesAnthropicModernModel(modelId: string): boolean {
return supportsClaudeAdaptiveThinking({ id: modelId });
return supportsClaudeAdaptiveThinking({ id: modelId }) || isAnthropicMythosPreviewModel(modelId);
}
function hasImageInput(input: unknown): boolean {

View File

@@ -461,4 +461,24 @@ describe("browser manage output", () => {
expect(output).toContain("OK gateway: browser control endpoint reachable");
expect(output).toContain("OK tabs: 1 visible, use tab reference t1");
});
it("prints a readable browser doctor failure when gateway auth SecretRefs are unavailable", async () => {
const error = Object.assign(new Error("gateway.auth.password unavailable"), {
code: "GATEWAY_SECRET_REF_UNAVAILABLE",
name: "GatewaySecretRefUnavailableError",
});
getBrowserManageCallBrowserRequestMock().mockRejectedValueOnce(error);
const program = createBrowserManageProgram();
await expect(program.parseAsync(["browser", "doctor"], { from: "user" })).rejects.toThrow(
"__exit__:1",
);
const output = lastRuntimeLog();
expect(output).toContain(
"FAIL gateway: Gateway auth SecretRef is unavailable in this command path",
);
expect(output).toContain("OPENCLAW_GATEWAY_TOKEN");
expect(output).not.toContain("GatewaySecretRefUnavailableError");
});
});

View File

@@ -152,6 +152,24 @@ function formatDoctorLine(check: BrowserDoctorCheck): string {
return `${check.ok ? "OK" : "FAIL"} ${check.name}${check.detail ? `: ${check.detail}` : ""}`;
}
function isGatewaySecretRefUnavailableErrorShape(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const errorRecord = error as Error & { code?: unknown };
return (
errorRecord.name === "GatewaySecretRefUnavailableError" ||
errorRecord.code === "GATEWAY_SECRET_REF_UNAVAILABLE"
);
}
function formatBrowserDoctorGatewayError(error: unknown): string {
if (!isGatewaySecretRefUnavailableErrorShape(error)) {
return String(error);
}
return "Gateway auth SecretRef is unavailable in this command path; browser doctor cannot reach the admin-scoped browser.request endpoint. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD, then retry.";
}
async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, deep?: boolean) {
const checks: BrowserDoctorCheck[] = [];
let status: BrowserStatus | null;
@@ -167,7 +185,7 @@ async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, dee
checks.push({
name: "gateway",
ok: false,
detail: String(err),
detail: formatBrowserDoctorGatewayError(err),
});
return { ok: false, checks };
}

View File

@@ -120,6 +120,7 @@ describe("canvas host", () => {
};
let createCanvasHostHandler: typeof import("./server.js").createCanvasHostHandler;
let startCanvasHost: typeof import("./server.js").startCanvasHost;
let canvasLiveReloadMaxInboundMessageBytes = 0;
let WebSocketServerClass: typeof import("ws").WebSocketServer;
let watcherState: ReturnType<typeof createMockWatcherState>;
let fixtureRoot = "";
@@ -162,7 +163,10 @@ describe("canvas host", () => {
};
});
vi.resetModules();
({ createCanvasHostHandler, startCanvasHost } = await import("./server.js"));
const serverModule = await import("./server.js");
({ createCanvasHostHandler, startCanvasHost } = serverModule);
canvasLiveReloadMaxInboundMessageBytes =
serverModule.CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES;
const wsModule = await vi.importActual<typeof import("ws")>("ws");
WebSocketServerClass = wsModule.WebSocketServer;
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-fixtures-"));
@@ -221,6 +225,54 @@ describe("canvas host", () => {
}
});
it("caps live reload WebSocket inbound payloads", async () => {
const dir = await createCaseDir();
const constructorOptions: unknown[] = [];
let connectionHandler: ((socket: TrackingWebSocket) => void) | undefined;
class CapturingWebSocketServer {
on(event: string, cb: (socket: TrackingWebSocket) => void) {
if (event === "connection") {
connectionHandler = cb;
}
return this;
}
close(cb?: () => void) {
cb?.();
}
constructor(options: unknown) {
constructorOptions.push(options);
}
}
const handler = await createTestCanvasHostHandler(dir, {
webSocketServerClass:
CapturingWebSocketServer as unknown as typeof import("ws").WebSocketServer,
});
try {
expect(constructorOptions[0]).toMatchObject({
noServer: true,
maxPayload: canvasLiveReloadMaxInboundMessageBytes,
});
const socketHandlers: string[] = [];
const socket: TrackingWebSocket = {
sent: [],
on: (event) => {
socketHandlers.push(event);
return socket;
},
send: vi.fn(),
};
expect(connectionHandler).toBeDefined();
connectionHandler?.(socket);
expect(socketHandlers).toEqual(expect.arrayContaining(["error", "close"]));
} finally {
await handler.close();
}
});
it("falls back to the default mount when the configured base path is malformed", async () => {
const dir = await createCaseDir();
await fs.writeFile(path.join(dir, "index.html"), "<html><body>fallback</body></html>", "utf8");

View File

@@ -30,6 +30,8 @@ import {
} from "./a2ui-shared.js";
import { normalizeUrlPath, resolveFileWithinRoot } from "./file-resolver.js";
export const CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES = 64 * 1024;
type ChokidarWatch = typeof import("chokidar").watch;
/** Options for Canvas host creation. */
@@ -276,11 +278,22 @@ export async function createCanvasHostHandler(
const writeStabilityThresholdMs = testMode ? 12 : 75;
const writePollIntervalMs = testMode ? 5 : 10;
const WebSocketServerClass = opts.webSocketServerClass ?? WebSocketServer;
const wss = liveReload ? new WebSocketServerClass({ noServer: true }) : null;
const wss = liveReload
? new WebSocketServerClass({
noServer: true,
// Live reload clients never need to send application payloads; cap frames
// before ws buffers oversized input on this long-lived upgrade route.
maxPayload: CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES,
})
: null;
const sockets = new Set<WebSocket>();
if (wss) {
wss.on("connection", (ws) => {
sockets.add(ws);
// ws emits error for maxPayload rejections; close handles final cleanup.
ws.on("error", () => {
sockets.delete(ws);
});
ws.on("close", () => sockets.delete(ws));
});
}

View File

@@ -15,6 +15,7 @@ import {
type EmbeddedRunAttemptResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
import type { CodexDynamicToolSpec, JsonValue } from "./protocol.js";
import { isJsonObject } from "./protocol.js";
import type { CodexAppServerThreadBinding } from "./session-binding.js";
@@ -249,9 +250,11 @@ export async function buildCodexWorkspaceBootstrapContext(params: {
turnScopedDeveloperInstructionFiles,
),
memoryCollaborationInstructions: shouldInjectCodexOpenClawPromptContext(params.params)
? renderCodexWorkspaceMemoryReference({
? renderCodexWorkspaceMemoryCollaborationInstructions({
files: memoryReferenceFiles,
toolNames: params.memoryToolNames,
memoryToolRouted: memoryToolsAvailable,
citationsMode: params.params.config?.memory?.citations,
})
: undefined,
heartbeatCollaborationInstructions:
@@ -805,6 +808,55 @@ export function renderCodexWorkspaceMemoryReference(params: {
return lines.join("\n").trim();
}
function renderCodexWorkspaceMemoryCollaborationInstructions(params: {
files: EmbeddedContextFile[];
toolNames: readonly string[];
memoryToolRouted: boolean;
citationsMode?: Parameters<typeof buildMemorySystemPromptAddition>[0]["citationsMode"];
}): string | undefined {
const memoryRecallInstructions = params.memoryToolRouted
? renderCodexMemoryRecallInstructions({
toolNames: params.toolNames,
citationsMode: params.citationsMode,
})
: undefined;
const memoryReferenceInstructions = renderCodexWorkspaceMemoryReference({
files: params.files,
toolNames: params.toolNames,
});
const sections = [memoryRecallInstructions, memoryReferenceInstructions].filter(isNonEmptyString);
return sections.length > 0 ? sections.join("\n\n") : undefined;
}
function renderCodexMemoryRecallInstructions(params: {
toolNames: readonly string[];
citationsMode?: Parameters<typeof buildMemorySystemPromptAddition>[0]["citationsMode"];
}): string | undefined {
const availableTools = new Set(params.toolNames);
const memoryPrompt = buildMemorySystemPromptAddition({
availableTools,
citationsMode: params.citationsMode,
});
if (!memoryPrompt) {
// Memory recall policy belongs to the active memory plugin.
// Codex-side fallback text can mask plugin lifecycle bugs or misdescribe third-party memory tools.
return undefined;
}
const toolSearchBridge = renderCodexMemoryToolSearchBridge(params.toolNames);
return [memoryPrompt, toolSearchBridge].filter(isNonEmptyString).join("\n").trim();
}
function renderCodexMemoryToolSearchBridge(toolNames: readonly string[]): string | undefined {
const memoryToolNames = toolNames
.map((name) => normalizeCodexDynamicToolName(name))
.filter((name) => CODEX_MEMORY_TOOL_NAMES.has(name))
.toSorted();
if (memoryToolNames.length === 0) {
return undefined;
}
return `Codex may expose ${memoryToolNames.join(" and ")} as deferred tools. When the memory guidance above calls for memory recall, use an already-loaded memory tool directly. If the needed memory tool is deferred and not currently callable, use \`tool_search\` to load it, then call that memory tool.`;
}
/** Returns whether the current dynamic tool list can serve workspace memory. */
export function hasCodexWorkspaceMemoryTools(tools: readonly { name: string }[]): boolean {
return getCodexWorkspaceMemoryToolNames(tools).length > 0;

View File

@@ -10,6 +10,7 @@ import {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resetDiagnosticEventsForTest } from "openclaw/plugin-sdk/diagnostic-runtime";
import { clearInternalHooks, resetGlobalHookRunner } from "openclaw/plugin-sdk/hook-runtime";
import { clearMemoryPluginState } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { clearPluginCommands } from "openclaw/plugin-sdk/plugin-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { afterEach, beforeEach, expect, vi } from "vitest";
@@ -495,6 +496,7 @@ export function setupRunAttemptTestHooks(): void {
beforeEach(async () => {
vi.useRealTimers();
clearInternalHooks();
clearMemoryPluginState();
resetAgentEventsForTest();
resetDiagnosticEventsForTest();
vi.stubEnv("OPENCLAW_TRAJECTORY", "0");
@@ -512,6 +514,7 @@ export function setupRunAttemptTestHooks(): void {
testing.clearPendingCodexNativeHookRelayUnregistersForTests();
resetCodexRateLimitCacheForTests();
nativeHookRelayTesting.clearNativeHookRelaysForTests();
clearMemoryPluginState();
clearPluginCommands();
resetAgentEventsForTest();
resetDiagnosticEventsForTest();

View File

@@ -12,6 +12,7 @@ import {
type DiagnosticEventPayload,
} from "openclaw/plugin-sdk/diagnostic-runtime";
import { initializeGlobalHookRunner, registerInternalHook } from "openclaw/plugin-sdk/hook-runtime";
import { registerMemoryCapability } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it, vi } from "vitest";
@@ -397,6 +398,37 @@ function createRuntimeDynamicTool(name: string): RuntimeDynamicToolForTest {
};
}
function registerMemoryPromptForTest() {
registerMemoryCapability("memory-core", {
promptBuilder({ availableTools }) {
const hasMemorySearch = availableTools.has("memory_search");
const hasMemoryGet = availableTools.has("memory_get");
if (hasMemorySearch && hasMemoryGet) {
return [
"## Memory Recall",
"Test recall: run memory_search on MEMORY.md + memory/*.md + indexed session transcripts; then use memory_get.",
"",
];
}
if (hasMemorySearch) {
return [
"## Memory Recall",
"Test recall: run memory_search on MEMORY.md + memory/*.md + indexed session transcripts.",
"",
];
}
if (hasMemoryGet) {
return [
"## Memory Recall",
"Test recall: run memory_get for a specific memory file or note.",
"",
];
}
return [];
},
});
}
function buildEmptyCodexToolTelemetry(): CodexAppServerToolTelemetry {
return {
didSendViaMessagingTool: false,
@@ -2203,6 +2235,7 @@ describe("runCodexAppServerAttempt", () => {
await fs.writeFile(path.join(workspaceDir, "TOOLS.md"), toolGuidance);
await fs.writeFile(path.join(workspaceDir, "USER.md"), userProfile);
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary);
registerMemoryPromptForTest();
testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("memory_search"),
createRuntimeDynamicTool("memory_get"),
@@ -2236,12 +2269,20 @@ describe("runCodexAppServerAttempt", () => {
expect(collaborationInstructions).toContain(identityGuidance);
expect(collaborationInstructions).not.toContain(toolGuidance);
expect(collaborationInstructions).toContain(userProfile);
expect(collaborationInstructions).toContain("## Memory Recall");
expect(collaborationInstructions).toContain("MEMORY.md + memory/*.md");
expect(collaborationInstructions).toContain("OpenClaw Workspace Memory");
expect(collaborationInstructions).toContain(
"MEMORY.md exists in the active agent workspace as a memory file, not an instruction file",
);
expect(collaborationInstructions).toContain("memory_search");
expect(collaborationInstructions).toContain("memory_get");
expect(collaborationInstructions).toContain(
"When the memory guidance above calls for memory recall, use an already-loaded memory tool directly.",
);
expect(collaborationInstructions).toContain(
"If the needed memory tool is deferred and not currently callable, use `tool_search` to load it, then call that memory tool.",
);
expect(collaborationInstructions).not.toContain(memorySummary);
expect(inputText).not.toContain("OpenClaw runtime context for this turn:");
expect(inputText).not.toContain("does not override Codex system/developer instructions");
@@ -2297,6 +2338,65 @@ describe("runCodexAppServerAttempt", () => {
});
});
it("adds memory recall guidance when dated memory notes exist without root MEMORY.md", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const datedMemory = "User avoids Chase cards while over 5/24.";
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
await fs.writeFile(path.join(workspaceDir, "memory/2026-06-09.md"), datedMemory);
registerMemoryPromptForTest();
testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("memory_search"),
createRuntimeDynamicTool("memory_get"),
]);
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
setAgentWorkspaceForTest(params, workspaceDir);
const { collaborationInstructions, inputText } = await buildCodexTurnContextForTest(
params,
workspaceDir,
);
expect(collaborationInstructions).toContain("## Memory Recall");
expect(collaborationInstructions).toContain("MEMORY.md + memory/*.md");
expect(collaborationInstructions).toContain("memory_search");
expect(collaborationInstructions).toContain("memory_get");
expect(collaborationInstructions).not.toContain("OpenClaw Workspace Memory");
expect(collaborationInstructions).not.toContain(datedMemory);
expect(inputText).toBe("hello");
expect(inputText).not.toContain(datedMemory);
});
it("does not synthesize memory recall guidance without a registered memory prompt builder", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const memorySummary = "User avoids Chase cards while over 5/24.";
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary);
testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("memory_search"),
createRuntimeDynamicTool("memory_get"),
]);
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
setAgentWorkspaceForTest(params, workspaceDir);
const { collaborationInstructions, inputText } = await buildCodexTurnContextForTest(
params,
workspaceDir,
);
expect(collaborationInstructions).not.toContain("## Memory Recall");
expect(collaborationInstructions).toContain("OpenClaw Workspace Memory");
expect(collaborationInstructions).not.toContain("Use `tool_search` first");
expect(collaborationInstructions).not.toContain(memorySummary);
expect(inputText).toBe("hello");
expect(inputText).not.toContain(memorySummary);
});
it("sends workspace bootstrap instructions through Codex app-server payloads", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -2405,6 +2505,7 @@ describe("runCodexAppServerAttempt", () => {
const memorySummary = "Memory summary goes here.";
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary);
registerMemoryPromptForTest();
testing.setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("memory_get")]);
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
@@ -2417,6 +2518,7 @@ describe("runCodexAppServerAttempt", () => {
expect(inputText).not.toContain("memory_get");
expect(inputText).not.toContain("memory_search");
expect(inputText).not.toContain(memorySummary);
expect(collaborationInstructions).toContain("## Memory Recall");
expect(collaborationInstructions).toContain("OpenClaw Workspace Memory");
expect(collaborationInstructions).toContain("memory_get");
expect(collaborationInstructions).not.toContain("memory_search");
@@ -2595,6 +2697,7 @@ describe("runCodexAppServerAttempt", () => {
const memorySummary = "Memory summary goes here.";
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary);
registerMemoryPromptForTest();
testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("memory_search"),
createRuntimeDynamicTool("memory_get"),
@@ -2604,10 +2707,10 @@ describe("runCodexAppServerAttempt", () => {
params.runtimePlan = createCodexRuntimePlanFixture();
setAgentWorkspaceForTest(params, path.join(tempDir, "memory-workspace"));
const { inputText, systemPromptReport } = await buildCodexTurnContextForTest(
params,
workspaceDir,
);
const { collaborationInstructions, inputText, systemPromptReport } =
await buildCodexTurnContextForTest(params, workspaceDir);
expect(collaborationInstructions).not.toContain("## Memory Recall");
expect(collaborationInstructions).not.toContain("OpenClaw Workspace Memory");
expect(inputText).not.toContain("OpenClaw Workspace Memory");
expect(inputText).toContain(memorySummary);

View File

@@ -1,6 +1,7 @@
// Codex tests cover sandbox exec server plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES,
closeCodexSandboxExecServersForTests,
ensureCodexSandboxExecServerEnvironment,
releaseCodexSandboxExecServerEnvironment,
@@ -191,6 +192,22 @@ describe("OpenClaw Codex sandbox exec-server", () => {
socket.close();
});
it("closes oversized sandbox exec-server frames before JSON-RPC parsing", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
const closed = waitForSocketClose(socket);
socket.send(Buffer.alloc(CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES + 1));
await expect(closed).resolves.toEqual({ code: 1009 });
});
it("rejects unsupported arg0 overrides instead of dropping them", async () => {
const buildExecSpec = vi.fn(async () => ({
argv: [process.execPath, "-e", ""],
@@ -441,6 +458,26 @@ describe("OpenClaw Codex sandbox exec-server", () => {
await expect(waitForSocketClose(socket)).resolves.toEqual({ code: 1008 });
});
it("handles oversized frames from unauthorized WebSocket clients", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const unauthorizedUrl = execServerUrlFromClient(client).replace(
/\/openclaw-[^/?#]+/u,
"/wrong",
);
const socket = await openSocket(unauthorizedUrl);
const closed = waitForSocketClose(socket);
socket.send(Buffer.alloc(CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES + 1));
const closeResult = await closed;
expect([1008, 1009]).toContain(closeResult.code);
});
it("closes the exec-server when its sandbox environment is released", async () => {
const sandbox = createSandboxContext({});
const client = createClient();

View File

@@ -48,6 +48,7 @@ export type CodexSandboxExecEnvironment = {
};
const SANDBOX_EXEC_SERVERS = new Map<string, Promise<OpenClawExecServer>>();
export const CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES = 100 * 1024 * 1024;
/** Closes all cached sandbox exec-server instances for deterministic tests. */
export async function closeCodexSandboxExecServersForTests(): Promise<void> {
@@ -193,7 +194,13 @@ function startAndRememberOpenClawExecServer(sandbox: SandboxContext): Promise<Op
}
async function startOpenClawExecServer(sandbox: SandboxContext): Promise<OpenClawExecServer> {
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
const server = new WebSocketServer({
host: "127.0.0.1",
port: 0,
// Match ws' historical default: Codex fs/writeFile sends one base64 JSON-RPC
// frame, while the socket error handler below makes oversize frames nonfatal.
maxPayload: CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES,
});
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") {
@@ -212,6 +219,8 @@ async function startOpenClawExecServer(sandbox: SandboxContext): Promise<OpenCla
server,
};
server.on("connection", (socket, request) => {
// ws emits error for maxPayload rejections before auth or JSON-RPC sees the frame.
socket.on("error", handleExecServerSocketError);
if (!isAuthorizedExecServerRequest(execServer, request)) {
socket.close(1008, "unauthorized");
return;
@@ -286,6 +295,10 @@ function handleConnection(execServer: OpenClawExecServer, socket: WebSocket): vo
});
}
function handleExecServerSocketError(error: unknown): void {
embeddedAgentLog.debug("codex sandbox exec-server websocket failed", { error });
}
async function handleMessage(
execServer: OpenClawExecServer,
processes: Map<string, ManagedProcess>,

View File

@@ -171,6 +171,7 @@ import {
type DiagnosticEventPrivateData,
} from "openclaw/plugin-sdk/diagnostic-runtime";
import {
emitDiagnosticEventWithTrustedTraceContext,
emitInternalDiagnosticEventForTest,
logMessageDispatchStarted,
logMessageProcessed,
@@ -362,7 +363,11 @@ function histogramCreateOptions(name: string) {
async function emitAndCaptureLog(
event: Omit<Extract<Parameters<typeof emitDiagnosticEvent>[0], { type: "log.record" }>, "type">,
options: { captureContent?: OtelContextFlags["captureContent"]; trusted?: boolean } = {},
options: {
captureContent?: OtelContextFlags["captureContent"];
trusted?: boolean;
trustedTraceContext?: boolean;
} = {},
) {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, {
@@ -370,7 +375,11 @@ async function emitAndCaptureLog(
...(options.captureContent !== undefined ? { captureContent: options.captureContent } : {}),
});
await service.start(ctx);
const emit = options.trusted ? emitTrustedDiagnosticEvent : emitDiagnosticEvent;
const emit = options.trusted
? emitTrustedDiagnosticEvent
: options.trustedTraceContext
? emitDiagnosticEventWithTrustedTraceContext
: emitDiagnosticEvent;
emit({
type: "log.record",
...event,
@@ -1391,6 +1400,28 @@ describe("diagnostics-otel service", () => {
expect(emitCall?.context).toBeUndefined();
});
test("attaches trace-only trusted context to exported logs", async () => {
const emitCall = await emitAndCaptureLog(
{
level: "INFO",
message: "traceable log",
trace: {
traceId: TRACE_ID,
spanId: SPAN_ID,
traceFlags: "01",
},
},
{ trustedTraceContext: true },
);
expect(emitCall?.body).toBe("log");
expect(telemetryState.tracer.setSpanContext).toHaveBeenCalledTimes(1);
const emitContext = emitCall?.context as { spanContext?: Record<string, unknown> } | undefined;
const emitSpanContext = emitContext?.spanContext;
expect(emitSpanContext?.traceId).toBe(TRACE_ID);
expect(emitSpanContext?.spanId).toBe(SPAN_ID);
});
test("attaches trusted diagnostic trace context to exported logs", async () => {
const emitCall = await emitAndCaptureLog(
{

View File

@@ -1031,7 +1031,9 @@ function contextForTrustedTraceContext(
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
) {
return metadata.trusted ? contextForTraceContext(evt.trace) : undefined;
return metadata.trusted || metadata.trustedTraceContext === true
? contextForTraceContext(evt.trace)
: undefined;
}
function addTraceAttributes(
@@ -1626,7 +1628,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
if (evt.code?.functionName) {
assignOtelLogAttribute(attributes, "code.function", evt.code.functionName);
}
if (metadata.trusted) {
if (metadata.trusted || metadata.trustedTraceContext === true) {
addTraceAttributes(attributes, evt.trace);
}

View File

@@ -24,6 +24,75 @@ function hasDiscordComponentObjectKeys(value: unknown): value is Record<string,
);
}
function readDiscordThreadArchiveTimestamp(thread: unknown): string | undefined {
if (!thread || typeof thread !== "object" || Array.isArray(thread)) {
return undefined;
}
const record = thread as Record<string, unknown>;
const metadata = record.thread_metadata;
if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
const archiveTimestamp = (metadata as Record<string, unknown>).archive_timestamp;
if (typeof archiveTimestamp === "string" && archiveTimestamp.trim()) {
return archiveTimestamp;
}
}
return undefined;
}
type DiscordThreadListActionResult = {
ok: true;
threads: unknown;
complete: boolean;
hasMore: boolean;
returnedCount: number;
source: "discord.threadList.archived" | "discord.threadList.active";
query: {
guildId: string;
channelId?: string;
includeArchived: boolean;
before?: string;
limit?: number;
};
nextBefore?: string;
};
function normalizeDiscordThreadListActionResult(params: {
value: unknown;
includeArchived: boolean;
channelId?: string;
guildId: string;
limit?: number;
before?: string;
}): DiscordThreadListActionResult {
const record =
params.value && typeof params.value === "object" && !Array.isArray(params.value)
? (params.value as Record<string, unknown>)
: undefined;
const threadItems = Array.isArray(record?.threads) ? record.threads : [];
const hasMore = record?.has_more === true;
const nextBefore =
params.includeArchived && hasMore
? readDiscordThreadArchiveTimestamp(threadItems[threadItems.length - 1])
: undefined;
return {
ok: true,
threads: params.value,
complete: !hasMore,
hasMore,
returnedCount: threadItems.length,
source: params.includeArchived ? "discord.threadList.archived" : "discord.threadList.active",
query: {
guildId: params.guildId,
...(params.channelId ? { channelId: params.channelId } : {}),
includeArchived: params.includeArchived,
...(params.before ? { before: params.before } : {}),
...(params.limit !== undefined ? { limit: params.limit } : {}),
},
...(nextBefore ? { nextBefore } : {}),
};
}
async function appendDiscordThreadRenameResult(
ctx: DiscordMessagingActionContext,
params: {
@@ -306,7 +375,16 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction
},
ctx.withOpts(),
);
return jsonResult({ ok: true, threads });
return jsonResult(
normalizeDiscordThreadListActionResult({
value: threads,
guildId,
channelId,
includeArchived: includeArchived === true,
before,
limit,
}),
);
}
case "threadReply": {
if (!ctx.isActionEnabled("threads")) {

View File

@@ -101,6 +101,7 @@ const {
kickMemberDiscord,
listGuildChannelsDiscord,
listPinsDiscord,
listThreadsDiscord,
moveChannelDiscord,
reactMessageDiscord,
readMessagesDiscord,
@@ -271,6 +272,138 @@ describe("handleDiscordMessagingAction", () => {
]);
});
it("surfaces incomplete archived thread pages at the action boundary", async () => {
listThreadsDiscord.mockResolvedValueOnce({
threads: [
{
id: "thread-1",
name: "Old project",
thread_metadata: {
archive_timestamp: "2026-05-25T17:00:00.000Z",
},
},
],
members: [],
has_more: true,
});
const result = await handleMessagingAction(
"threadList",
{
guildId: "G1",
channelId: "C1",
includeArchived: true,
before: "2026-05-26T17:00:00.000Z",
limit: 1,
},
enableAllActions,
);
expect(mockCall(listThreadsDiscord, "listThreadsDiscord")).toEqual([
{
guildId: "G1",
channelId: "C1",
includeArchived: true,
before: "2026-05-26T17:00:00.000Z",
limit: 1,
},
{ cfg: DISCORD_TEST_CFG },
]);
expect(result.details).toMatchObject({
ok: true,
complete: false,
hasMore: true,
returnedCount: 1,
source: "discord.threadList.archived",
nextBefore: "2026-05-25T17:00:00.000Z",
query: {
guildId: "G1",
channelId: "C1",
includeArchived: true,
before: "2026-05-26T17:00:00.000Z",
limit: 1,
},
});
expect((result.details as { threads?: unknown }).threads).toEqual({
threads: [
{
id: "thread-1",
name: "Old project",
thread_metadata: {
archive_timestamp: "2026-05-25T17:00:00.000Z",
},
},
],
members: [],
has_more: true,
});
});
it("omits archived thread pagination cursors when Discord omits archive timestamps", async () => {
listThreadsDiscord.mockResolvedValueOnce({
threads: [
{
id: "thread-without-archive-timestamp",
name: "Legacy project",
},
],
members: [],
has_more: true,
});
const result = await handleMessagingAction(
"threadList",
{
guildId: "G1",
channelId: "C1",
includeArchived: true,
limit: 1,
},
enableAllActions,
);
expect(result.details).toMatchObject({
ok: true,
complete: false,
hasMore: true,
returnedCount: 1,
source: "discord.threadList.archived",
});
expect(result.details).not.toHaveProperty("nextBefore");
});
it("marks active thread results complete when Discord returns no pagination state", async () => {
listThreadsDiscord.mockResolvedValueOnce({
threads: [{ id: "thread-active", name: "Current project" }],
members: [{ id: "member-1" }],
});
const result = await handleMessagingAction(
"threadList",
{
guildId: "G1",
},
enableAllActions,
);
expect(result.details).toMatchObject({
ok: true,
complete: true,
hasMore: false,
returnedCount: 1,
source: "discord.threadList.active",
query: {
guildId: "G1",
includeArchived: false,
},
});
expect((result.details as { threads?: unknown }).threads).toEqual({
threads: [{ id: "thread-active", name: "Current project" }],
members: [{ id: "member-1" }],
});
expect(result.details).not.toHaveProperty("nextBefore");
});
it("resolves Discord DM targets for reaction adds", async () => {
const resolveReactionTarget = vi.fn(async () => "DM1");
discordMessagingActionRuntime.resolveDiscordReactionTargetChannelId = resolveReactionTarget;

View File

@@ -1,7 +1,13 @@
// Discord tests cover command deploy plugin behavior.
import type { APIApplicationCommand } from "discord-api-types/v10";
import { describe, expect, test } from "vitest";
import { testing } from "./command-deploy.js";
/* oxlint-disable typescript/unbound-method -- vitest mocks of RequestClient methods (createRest) intentionally expose vi.fn refs via `restA.get`/`.post`; not unbound class methods. */
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { ApplicationCommandType, type APIApplicationCommand } from "discord-api-types/v10";
import { describe, expect, test, vi } from "vitest";
import { DiscordCommandDeployer, testing } from "./command-deploy.js";
import { BaseCommand } from "./commands.js";
import type { RequestClient } from "./rest.js";
const { commandsEqual } = testing;
@@ -196,3 +202,332 @@ describe("commandsEqual", () => {
expect(commandsEqual(current, desired)).toBe(true);
});
});
/**
* Regression for #77359: when two Discord accounts share the same on-disk
* deploy-cache file (the default in multi-bot setups) the persisted hash key
* must be scoped by application/client id. Otherwise a later account whose
* command set hashes the same as the first account's reuses the first
* account's hash and skips reconciling its own Discord application — leaving
* "This application has no commands" in the secondary bot's Integrations panel.
*/
describe("DiscordCommandDeployer cache scoping (multi-application)", () => {
class StaticCommand extends BaseCommand {
name: string;
override description = "ping the bot";
type = ApplicationCommandType.ChatInput;
constructor(name: string) {
super();
this.name = name;
}
serializeOptions() {
return undefined;
}
}
function createRest(): RequestClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => undefined),
patch: vi.fn(async () => undefined),
put: vi.fn(async () => undefined),
delete: vi.fn(async () => undefined),
} as unknown as RequestClient;
}
test("two applications with identical command sets each reconcile their own application", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-multi-app-"));
const hashStorePath = path.join(dir, "command-deploy-cache.json");
const commands = [new StaticCommand("ping")];
const restA = createRest();
const deployerA = new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStorePath,
rest: () => restA,
});
await deployerA.deploy({ mode: "reconcile" });
const restB = createRest();
const deployerB = new DiscordCommandDeployer({
clientId: "app-secondary",
commands,
hashStorePath,
rest: () => restB,
});
await deployerB.deploy({ mode: "reconcile" });
// The first deploy issues a list + create against application "app-default".
expect(restA.get).toHaveBeenCalledTimes(1);
expect(restA.post).toHaveBeenCalledTimes(1);
// The second deploy MUST also list + create against "app-secondary"; before
// the fix it short-circuited on the shared `global:reconcile` hash and
// never touched its own Discord application.
expect(restB.get).toHaveBeenCalledTimes(1);
expect(restB.post).toHaveBeenCalledTimes(1);
});
test("re-deploying the same application still hits the persisted cache", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-multi-app-"));
const hashStorePath = path.join(dir, "command-deploy-cache.json");
const commands = [new StaticCommand("ping")];
const restFirst = createRest();
await new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStorePath,
rest: () => restFirst,
}).deploy({ mode: "reconcile" });
const restSecond = createRest();
await new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStorePath,
rest: () => restSecond,
}).deploy({ mode: "reconcile" });
expect(restFirst.get).toHaveBeenCalledTimes(1);
expect(restFirst.post).toHaveBeenCalledTimes(1);
// Same application, same command set, same hash file => skip reconcile.
expect(restSecond.get).not.toHaveBeenCalled();
expect(restSecond.post).not.toHaveBeenCalled();
});
test("persisted cache keys are namespaced by application id", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-multi-app-"));
const hashStorePath = path.join(dir, "command-deploy-cache.json");
const commands = [new StaticCommand("ping")];
await new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStorePath,
rest: () => createRest(),
}).deploy({ mode: "reconcile" });
await new DiscordCommandDeployer({
clientId: "app-secondary",
commands,
hashStorePath,
rest: () => createRest(),
}).deploy({ mode: "reconcile" });
const raw = await fs.readFile(hashStorePath, "utf8");
const parsed = JSON.parse(raw) as { hashes: Record<string, string> };
const keys = Object.keys(parsed.hashes);
expect(keys).toContain("app:app-default:global:reconcile");
expect(keys).toContain("app:app-secondary:global:reconcile");
expect(keys).not.toContain("global:reconcile");
});
test("successful deploy repairs a corrupt persisted cache file", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-multi-app-"));
const hashStorePath = path.join(dir, "command-deploy-cache.json");
await fs.writeFile(hashStorePath, "{not json", "utf8");
await new DiscordCommandDeployer({
clientId: "app-default",
commands: [new StaticCommand("ping")],
hashStorePath,
rest: () => createRest(),
}).deploy({ mode: "reconcile" });
const raw = await fs.readFile(hashStorePath, "utf8");
const parsed = JSON.parse(raw) as { hashes: Record<string, string> };
expect(parsed.hashes).toHaveProperty("app:app-default:global:reconcile");
});
test("a deployer that loaded an empty cache before another deployer's write preserves the other deployer's entries on persist", async () => {
// Regression for the codex follow-up on PR #77367: `server-channels.ts`
// can start multiple Discord deployers concurrently. Before the fix, a
// deployer that loaded the (empty) cache file before another deployer's
// first write would later overwrite it on its own `persistHashes()`,
// serializing only its own in-memory `app:<id>:...` entry and dropping
// the other deployer's entry. The current implementation re-reads the
// on-disk hashes inside `persistHashes` and merges them with our
// in-memory entries before the rename.
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-multi-app-"));
const hashStorePath = path.join(dir, "command-deploy-cache.json");
const commands = [new StaticCommand("ping")];
// Deployer B starts first, loads the empty cache. Then deployer A
// completes its full deploy + persist, writing `app:app-default:...` to
// disk. When deployer B finally persists, it must merge in deployer A's
// entry instead of overwriting it with just its own.
const deployerB = new DiscordCommandDeployer({
clientId: "app-secondary",
commands,
hashStorePath,
rest: () => createRest(),
});
// Trigger B's load of the (still missing) cache file by starting deploy
// and immediately awaiting just enough to clear the load. The deploy
// call awaits loadPersistedHashes inside putCommandSetIfChanged before
// calling deploy(); to keep the seam minimal here, we just race the load
// by running deployer A's full deploy in between.
const deployerA = new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStorePath,
rest: () => createRest(),
});
// Step 1: A runs a full deploy (load -> reconcile -> persist) on the
// initially missing cache file; result: file now has app-default entry.
await deployerA.deploy({ mode: "reconcile" });
// Step 2: B runs its full deploy. Without the fix, B's persistHashes
// would write only `app:app-secondary:...` and drop A's entry. With the
// fix, B re-reads the on-disk file inside persistHashes, sees A's entry,
// and merges it into the write so both keys survive.
await deployerB.deploy({ mode: "reconcile" });
const raw = await fs.readFile(hashStorePath, "utf8");
const parsed = JSON.parse(raw) as { hashes: Record<string, string> };
const keys = Object.keys(parsed.hashes);
expect(keys).toContain("app:app-default:global:reconcile");
expect(keys).toContain("app:app-secondary:global:reconcile");
// And subsequent restarts must still hit the cache for both apps,
// proving the rate-limit protection survived the concurrent write.
const restA = createRest();
await new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStorePath,
rest: () => restA,
}).deploy({ mode: "reconcile" });
const restB = createRest();
await new DiscordCommandDeployer({
clientId: "app-secondary",
commands,
hashStorePath,
rest: () => restB,
}).deploy({ mode: "reconcile" });
expect(restA.get).not.toHaveBeenCalled();
expect(restA.post).not.toHaveBeenCalled();
expect(restB.get).not.toHaveBeenCalled();
expect(restB.post).not.toHaveBeenCalled();
});
test("truly parallel deployers serialize cache writes via the per-path mutex (codex follow-up on #77367)", async () => {
// Codex follow-up on PR #77367: re-read-before-write alone isn't enough
// when two deployers run `persistHashes` in real parallel — both can read
// the same snapshot before either writes. The in-process per-path mutex
// around the read-merge-write cycle makes the operation atomic.
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-multi-app-"));
const hashStorePath = path.join(dir, "command-deploy-cache.json");
const commands = [new StaticCommand("ping")];
// Run BOTH deploys with Promise.all on the SAME process tick — pre-fix,
// both `persistHashes` calls would race on read-then-rename and one
// writer's `app:<id>:...` entry would be lost.
const restA = createRest();
const restB = createRest();
const restC = createRest();
await Promise.all([
new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStorePath,
rest: () => restA,
}).deploy({ mode: "reconcile" }),
new DiscordCommandDeployer({
clientId: "app-secondary",
commands,
hashStorePath,
rest: () => restB,
}).deploy({ mode: "reconcile" }),
new DiscordCommandDeployer({
clientId: "app-tertiary",
commands,
hashStorePath,
rest: () => restC,
}).deploy({ mode: "reconcile" }),
]);
const raw = await fs.readFile(hashStorePath, "utf8");
const parsed = JSON.parse(raw) as { hashes: Record<string, string> };
const keys = Object.keys(parsed.hashes);
// All three apps' entries must survive — pre-fix, one or two would be
// lost to the race.
expect(keys).toContain("app:app-default:global:reconcile");
expect(keys).toContain("app:app-secondary:global:reconcile");
expect(keys).toContain("app:app-tertiary:global:reconcile");
});
test("parallel changed deploys preserve fresher sibling cache entries", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-multi-app-"));
const hashStorePath = path.join(dir, "command-deploy-cache.json");
const oldCommands = [new StaticCommand("ping")];
const newCommands = [new StaticCommand("status")];
await new DiscordCommandDeployer({
clientId: "app-default",
commands: oldCommands,
hashStorePath,
rest: () => createRest(),
}).deploy({ mode: "reconcile" });
await new DiscordCommandDeployer({
clientId: "app-secondary",
commands: oldCommands,
hashStorePath,
rest: () => createRest(),
}).deploy({ mode: "reconcile" });
let postStarts = 0;
let releasePosts: () => void = () => {};
const bothPostsStarted = new Promise<void>((resolve) => {
releasePosts = resolve;
});
function createWaitingRest(): RequestClient {
const rest = createRest();
rest.post = vi.fn(async () => {
postStarts += 1;
if (postStarts === 2) {
releasePosts();
}
await bothPostsStarted;
}) as RequestClient["post"];
return rest;
}
await Promise.all([
new DiscordCommandDeployer({
clientId: "app-default",
commands: newCommands,
hashStorePath,
rest: () => createWaitingRest(),
}).deploy({ mode: "reconcile" }),
new DiscordCommandDeployer({
clientId: "app-secondary",
commands: newCommands,
hashStorePath,
rest: () => createWaitingRest(),
}).deploy({ mode: "reconcile" }),
]);
const restA = createRest();
await new DiscordCommandDeployer({
clientId: "app-default",
commands: newCommands,
hashStorePath,
rest: () => restA,
}).deploy({ mode: "reconcile" });
const restB = createRest();
await new DiscordCommandDeployer({
clientId: "app-secondary",
commands: newCommands,
hashStorePath,
rest: () => restB,
}).deploy({ mode: "reconcile" });
expect(restA.get).not.toHaveBeenCalled();
expect(restA.post).not.toHaveBeenCalled();
expect(restB.get).not.toHaveBeenCalled();
expect(restB.post).not.toHaveBeenCalled();
});
});

View File

@@ -21,8 +21,42 @@ export type DeployCommandOptions = {
type SerializedCommand = ReturnType<BaseCommand["serialize"]>;
/**
* Per-`command-deploy-cache.json` path async mutex. `server-channels.ts` can
* start several Discord deployers concurrently in the same Node.js process;
* each one shares the same on-disk cache file. Without this lock, two
* deployers can run `persistHashes` in parallel, both read the same on-disk
* snapshot before either writes, and the later `rename` then overwrites the
* earlier writer's entries — defeating the rate-limit cache.
*
* This is an in-process lock; cross-process serialization would need an OS
* file lock. Discord deployers only run inside the gateway process, so an
* in-process mutex is sufficient for the documented concurrency surface.
*/
const cachePersistLocks = new Map<string, Promise<void>>();
async function withCachePersistLock<T>(storePath: string, fn: () => Promise<T>): Promise<T> {
const previous = cachePersistLocks.get(storePath) ?? Promise.resolve();
let release: () => void = () => {};
const next = new Promise<void>((resolve) => {
release = resolve;
});
const chained = previous.then(() => next);
cachePersistLocks.set(storePath, chained);
try {
await previous;
return await fn();
} finally {
release();
if (cachePersistLocks.get(storePath) === chained) {
cachePersistLocks.delete(storePath);
}
}
}
export class DiscordCommandDeployer {
private readonly hashes = new Map<string, string>();
private readonly pendingHashes = new Map<string, string>();
private hashesLoaded = false;
constructor(
@@ -45,7 +79,7 @@ export class DiscordCommandDeployer {
const serializedGlobal = globalCommands.map((command) => command.serialize());
for (const [guildId, entries] of groupGuildCommands(commands)) {
await this.putCommandSetIfChanged(
`guild:${guildId}`,
this.scopedCacheKey(`guild:${guildId}`),
entries,
async () => {
await overwriteGuildApplicationCommands(
@@ -62,7 +96,7 @@ export class DiscordCommandDeployer {
for (const guildId of this.params.devGuilds) {
const entries = commands.map((command) => command.serialize());
await this.putCommandSetIfChanged(
`dev-guild:${guildId}`,
this.scopedCacheKey(`dev-guild:${guildId}`),
entries,
async () => {
await overwriteGuildApplicationCommands(
@@ -79,7 +113,7 @@ export class DiscordCommandDeployer {
}
if (options.mode !== "overwrite") {
await this.putCommandSetIfChanged(
"global:reconcile",
this.scopedCacheKey("global:reconcile"),
serializedGlobal,
async () => {
await this.reconcileGlobalCommands(serializedGlobal);
@@ -89,7 +123,7 @@ export class DiscordCommandDeployer {
return { mode: "reconcile" as const, usedDevGuilds: false };
}
await this.putCommandSetIfChanged(
"global:overwrite",
this.scopedCacheKey("global:overwrite"),
serializedGlobal,
async () => {
await overwriteApplicationCommands(this.rest, this.params.clientId, serializedGlobal);
@@ -99,6 +133,17 @@ export class DiscordCommandDeployer {
return { mode: "overwrite" as const, usedDevGuilds: false };
}
/**
* Scope cache keys by Discord application id so multi-bot setups that share a
* single deploy-cache file still reconcile each application separately. The
* prior unscoped `global:reconcile` / `guild:<id>` keys let a later account
* with an identical command set reuse the first account's hash and skip its
* own application's reconcile entirely (#77359).
*/
private scopedCacheKey(suffix: string): string {
return `app:${this.params.clientId}:${suffix}`;
}
private async reconcileGlobalCommands(desired: SerializedCommand[]) {
const existing = await this.getCommands();
const existingByKey = new Map(existing.map((command) => [stableCommandKey(command), command]));
@@ -135,6 +180,7 @@ export class DiscordCommandDeployer {
}
await deploy();
this.hashes.set(key, hash);
this.pendingHashes.set(key, hash);
await this.persistHashes();
}
@@ -169,18 +215,62 @@ export class DiscordCommandDeployer {
if (!storePath) {
return;
}
// Serialize concurrent persists for the same on-disk path. The earlier
// "re-read inside persistHashes" merge alone is not enough — two
// deployers running `persistHashes` in true parallel would both read the
// same snapshot before either writes, and the later `rename` would still
// overwrite the earlier one's `app:<id>:...` entries. The mutex makes the
// read-merge-write cycle atomic for in-process callers.
await withCachePersistLock(storePath, async () => {
await this.persistHashesLocked(storePath);
});
}
private async persistHashesLocked(storePath: string): Promise<void> {
try {
await privateFileStore(path.dirname(storePath)).writeJson(
path.basename(storePath),
// Re-read the on-disk hashes immediately before writing and merge only
// keys this deployer changed. Previously loaded hashes can be stale when
// sibling deployers update the same file, so on-disk wins for untouched
// keys while pending keys win because this deployer just produced them.
const storeFile = path.basename(storePath);
const fileStore = privateFileStore(path.dirname(storePath));
const merged = new Map<string, string>();
let onDisk: { hashes?: unknown } | null = null;
try {
onDisk = await fileStore.readJsonIfExists<{
hashes?: unknown;
}>(storeFile);
} catch {
// A corrupt cache should not become permanent. Treat the re-read as
// empty and replace it with the fresh pending hashes after deploy.
}
if (onDisk?.hashes && typeof onDisk.hashes === "object") {
for (const [key, value] of Object.entries(onDisk.hashes)) {
if (typeof value === "string" && key.trim() && value.trim()) {
merged.set(key, value);
}
}
}
for (const [key, value] of this.pendingHashes.entries()) {
merged.set(key, value);
}
await fileStore.writeJson(
storeFile,
{
version: 1,
updatedAt: new Date().toISOString(),
hashes: Object.fromEntries(
[...this.hashes.entries()].toSorted(([left], [right]) => left.localeCompare(right)),
[...merged.entries()].toSorted(([left], [right]) => left.localeCompare(right)),
),
},
{ trailingNewline: true },
);
// Refresh in-memory state so future writes from the same deployer also
// see entries that other deployers added concurrently.
for (const [key, value] of merged.entries()) {
this.hashes.set(key, value);
}
this.pendingHashes.clear();
} catch {
// The cache is only an optimization to avoid redundant Discord writes.
}

View File

@@ -148,6 +148,7 @@ type DispatchInboundParams = {
title?: string;
name?: string;
}) => Promise<void> | void;
onVerboseProgressVisibility?: (isActive: () => boolean) => void;
onPlanUpdate?: (payload: {
phase?: string;
explanation?: string;
@@ -2782,6 +2783,50 @@ describe("processDiscordMessage draft streaming", () => {
expect(updates).not.toContain("NO_REPLY");
});
it.each([
["active", true],
["inactive", false],
])(
"renders Discord commentary in the draft exactly when durable verbose progress is %s",
async (_label, durableLaneActive) => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
params?.replyOptions?.onVerboseProgressVisibility?.(() => durableLaneActive);
await params?.replyOptions?.onItemEvent?.({
itemId: "preamble-1",
kind: "preamble",
progressText: "Checking the current weather source before summarizing.",
});
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: false,
toolProgress: false,
commentary: true,
},
},
},
});
await runProcessDiscordMessage(ctx);
const updates = draftStream.update.mock.calls.map((call) => call[0]).join("\n");
if (durableLaneActive) {
// The durable verbose lane owns commentary: the ephemeral draft must
// not render it a second time.
expect(updates).toBe("");
} else {
expect(updates).toContain("Checking the current weather source");
}
},
);
it("keeps Discord progress drafts usable after the last commentary line becomes silent", async () => {
const draftStream = createMockDraftStreamForTest();

View File

@@ -557,6 +557,10 @@ async function processDiscordMessageInner(
chunkMode,
log: logVerbose,
});
// While the durable verbose commentary lane is active (dispatch reports it
// via onVerboseProgressVisibility), the ephemeral draft yields its commentary
// lines so commentary is not rendered in both lanes.
let verboseProgressActive: () => boolean = () => false;
const finalPreviewFlags =
(discordConfig?.suppressEmbeds ?? true) ? MessageFlags.SuppressEmbeds : undefined;
let finalReplyStartNotified = false;
@@ -1003,6 +1007,9 @@ async function processDiscordMessageInner(
commentaryProgressEnabled: draftPreview.isProgressMode
? draftPreview.commentaryProgressEnabled
: undefined,
onVerboseProgressVisibility: (isActive) => {
verboseProgressActive = isActive;
},
onReasoningStream: async (payload) => {
await statusReactions.setThinking();
await draftPreview.pushReasoningProgress(payload?.text, {
@@ -1031,6 +1038,9 @@ async function processDiscordMessageInner(
},
onItemEvent: async (payload) => {
if (payload.kind === "preamble") {
if (verboseProgressActive()) {
return;
}
if (draftPreview.commentaryProgressEnabled && payload.progressText) {
await draftPreview.pushCommentaryProgress(payload.progressText, {
itemId: payload.itemId,

View File

@@ -193,7 +193,7 @@ describe("Discord model picker preference migration", () => {
expect(plan.pluginId).toBe("discord");
expect(plan.namespace).toBe("thread-bindings");
const entries = await plan.readEntries();
expect(entries).toEqual([
expect(entries).toStrictEqual([
{
key: "default:legacy-thread",
value: {

View File

@@ -195,7 +195,10 @@ export const detectDiscordLegacyStateMigrations: BundledChannelLegacyStateMigrat
)) {
const normalized = normalizePersistedBinding(rawKey, rawEntry);
if (normalized) {
out.push({ key: toBindingRecordKey(normalized), value: normalized });
out.push({
key: toBindingRecordKey(normalized),
value: normalized,
});
}
}
return out;

View File

@@ -215,23 +215,36 @@ export function normalizePersistedBinding(
}
}
return {
const record: ThreadBindingRecord = {
accountId,
channelId,
threadId,
targetKind,
targetSessionKey,
agentId,
label,
webhookId,
webhookToken,
boundBy,
boundAt,
lastActivityAt,
idleTimeoutMs: migratedIdleTimeoutMs,
maxAgeMs: migratedMaxAgeMs,
metadata,
};
if (label !== undefined) {
record.label = label;
}
if (webhookId !== undefined) {
record.webhookId = webhookId;
}
if (webhookToken !== undefined) {
record.webhookToken = webhookToken;
}
if (migratedIdleTimeoutMs !== undefined) {
record.idleTimeoutMs = migratedIdleTimeoutMs;
}
if (migratedMaxAgeMs !== undefined) {
record.maxAgeMs = migratedMaxAgeMs;
}
if (metadata !== undefined) {
record.metadata = metadata;
}
return record;
}
export function normalizeThreadBindingDurationMs(raw: unknown, defaultsTo: number): number {

View File

@@ -172,6 +172,42 @@ describe("fal video generation provider", () => {
});
});
it("parses raw fal queue result payloads with top-level video output", async () => {
mockFalProviderRuntime();
fetchGuardMock
.mockResolvedValueOnce(
releasedJson({
request_id: "req-raw",
status_url: "https://queue.fal.run/fal-ai/wan/requests/req-raw/status",
response_url: "https://queue.fal.run/fal-ai/wan/requests/req-raw",
}),
)
.mockResolvedValueOnce(releasedJson({ status: "COMPLETED" }))
.mockResolvedValueOnce(
releasedJson({
video: { url: "https://fal.run/files/raw-output.mp4" },
prompt: "A calm harbor at sunrise",
seed: 443600358,
}),
)
.mockResolvedValueOnce(releasedVideo({ contentType: "video/mp4", bytes: "mp4-bytes" }));
const provider = buildFalVideoGenerationProvider();
const result = await provider.generateVideo({
provider: "fal",
model: "fal-ai/wan/v2.2-a14b/image-to-video",
prompt: "A calm harbor at sunrise",
cfg: {},
});
expect(result.videos[0]?.url).toBe("https://fal.run/files/raw-output.mp4");
expect(result.metadata).toEqual({
requestId: "req-raw",
prompt: "A calm harbor at sunrise",
seed: 443600358,
});
});
it("returns URL-only videos when generated video downloads exceed the configured media cap", async () => {
mockFalProviderRuntime();
mockCompletedFalVideoJob({

View File

@@ -166,6 +166,21 @@ function readFalQueueResponse(payload: unknown): FalQueueResponse {
};
}
function readFalCompletedQueueResult(payload: unknown): FalQueueResponse {
if (!isRecord(payload)) {
throw new Error(FAL_VIDEO_MALFORMED_RESPONSE);
}
if (
payload.response !== undefined ||
(payload.video === undefined && payload.videos === undefined)
) {
return readFalQueueResponse(payload);
}
return {
response: readFalVideoPayload(payload),
};
}
function toDataUrl(buffer: Buffer, mimeType: string): string {
return `data:${mimeType};base64,${buffer.toString("base64")}`;
}
@@ -509,7 +524,7 @@ async function waitForFalQueueResult(params: {
}
lastStatus = status;
if (status === "COMPLETED") {
return readFalQueueResponse(
return readFalCompletedQueueResult(
await fetchFalJson({
url: params.responseUrl,
init: {

View File

@@ -525,6 +525,40 @@ describe("handleFeishuMessage ACP routing", () => {
expect(message.text).toContain("runtime unavailable");
});
it("surfaces configured ACP initialization failures inside P2P direct-message threads", async () => {
mockResolveConfiguredBindingRoute.mockReturnValue(createConfiguredFeishuRoute());
mockEnsureConfiguredBindingRouteReady.mockResolvedValue(
createConfiguredBindingReadiness(false, "runtime unavailable"),
);
await dispatchMessage({
cfg: {
session: { mainKey: "main", scope: "per-sender" },
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
},
event: {
sender: { sender_id: { open_id: "ou_sender_1" } },
message: {
message_id: "msg-thread-child",
root_id: "msg-thread-root",
thread_id: "omt-acp-dm-thread",
chat_id: "oc_dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
},
});
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:oc_dm",
replyToMessageId: "msg-thread-root",
replyInThread: true,
}),
);
});
it("routes Feishu topic messages through active bound conversations", async () => {
mockResolveBoundConversation.mockReturnValue(createBoundConversation());
@@ -3359,6 +3393,79 @@ describe("handleFeishuMessage command authorization", () => {
expect(dispatcherOptions.rootId).toBe("om_topic_sender_root");
});
it("keeps P2P replies inside a direct-message thread when Feishu supplies thread_id", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-thread-dm" } },
message: {
message_id: "om_dm_thread_child",
root_id: "om_dm_thread_root",
thread_id: "omt_dm_thread",
chat_id: "oc-dm-thread",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello inside a DM thread" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_dm_thread_root",
rootId: "om_dm_thread_root",
skipReplyToInMessages: false,
replyInThread: true,
threadReply: true,
}),
);
});
it("keeps root_id-only P2P replies as quote replies outside thread mode", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-quote-dm" } },
message: {
message_id: "om_dm_quote_reply",
root_id: "om_dm_quote_root",
chat_id: "oc-dm-quote",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "quoted DM reply" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_dm_quote_reply",
rootId: "om_dm_quote_root",
skipReplyToInMessages: true,
replyInThread: false,
threadReply: false,
}),
);
});
it("forces thread replies when inbound message contains thread_id", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);

View File

@@ -802,7 +802,14 @@ export async function handleFeishuMessage(params: {
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
const directThreadReply = !isGroup && Boolean(ctx.threadId?.trim());
const defaultReplyTargetMessageId =
ctx.replyTargetMessageId ?? (ctx.suppressReplyTarget ? undefined : ctx.messageId);
const directThreadRootId = directThreadReply ? ctx.rootId?.trim() || undefined : undefined;
const directThreadReplyTargetMessageId = directThreadReply
? (directThreadRootId ?? defaultReplyTargetMessageId)
: undefined;
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : directThreadReply;
const feishuAcpConversationSupported =
!isGroup ||
groupSession?.groupSessionScope === "group_topic" ||
@@ -906,10 +913,13 @@ export async function handleFeishuMessage(params: {
bindingResolution: configuredBinding,
});
if (!ensured.ok) {
const replyTargetMessageId =
const acpTopicReply =
isGroup &&
(groupSession?.groupSessionScope === "group_topic" ||
groupSession?.groupSessionScope === "group_topic_sender")
groupSession?.groupSessionScope === "group_topic_sender");
const replyTargetMessageId = directThreadReply
? directThreadReplyTargetMessageId
: acpTopicReply
? (ctx.rootId ?? ctx.messageId)
: ctx.messageId;
await sendMessageFeishu({
@@ -917,7 +927,7 @@ export async function handleFeishuMessage(params: {
to: `chat:${ctx.chatId}`,
text: `⚠️ Failed to initialize the configured ACP session for this Feishu conversation: ${ensured.error}`,
replyToMessageId: replyTargetMessageId,
replyInThread: isGroup ? (groupSession?.replyInThread ?? false) : false,
replyInThread,
accountId: account.accountId,
}).catch((err: unknown) => {
log(`feishu[${account.accountId}]: failed to send ACP init error reply: ${String(err)}`);
@@ -1387,13 +1397,13 @@ export async function handleFeishuMessage(params: {
const configReplyInThread =
isGroup &&
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
const replyTargetMessageId =
isTopicSession || configReplyInThread
? (ctx.rootId ??
ctx.replyTargetMessageId ??
(ctx.suppressReplyTarget ? undefined : ctx.messageId))
: (ctx.replyTargetMessageId ?? (ctx.suppressReplyTarget ? undefined : ctx.messageId));
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
const topicReplyTargetMessageId = ctx.rootId ?? defaultReplyTargetMessageId;
const replyTargetMessageId = directThreadReply
? directThreadReplyTargetMessageId
: isTopicSession || configReplyInThread
? topicReplyTargetMessageId
: defaultReplyTargetMessageId;
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : directThreadReply;
const lastRouteThreadId =
isGroup && (isTopicSession || configReplyInThread || threadReply)
? replyTargetMessageId
@@ -1518,7 +1528,7 @@ export async function handleFeishuMessage(params: {
chatId: ctx.chatId,
allowReasoningPreview,
replyToMessageId: replyTargetMessageId,
skipReplyToInMessages: !isGroup,
skipReplyToInMessages: !isGroup && !directThreadReply,
replyInThread,
rootId: ctx.rootId,
threadReply,
@@ -1694,7 +1704,7 @@ export async function handleFeishuMessage(params: {
chatId: ctx.chatId,
allowReasoningPreview,
replyToMessageId: replyTargetMessageId,
skipReplyToInMessages: !isGroup,
skipReplyToInMessages: !isGroup && !directThreadReply,
replyInThread,
rootId: ctx.rootId,
threadReply,

View File

@@ -144,19 +144,19 @@ describe("fireworks provider plugin", () => {
expect(resolved?.reasoning).toBe(false);
});
it("disables reasoning metadata for Fireworks Kimi k2.6 dynamic models", async () => {
it("defers manifest catalog models to core static-catalog resolution", async () => {
const provider = await registerSingleProviderPlugin(fireworksPlugin);
const resolved = provider.resolveDynamicModel?.(
createProviderDynamicModelContext({
provider: "fireworks",
modelId: "accounts/fireworks/models/kimi-k2p6",
models: [createFireworksDefaultRuntimeModel({ reasoning: false })],
}),
);
for (const modelId of [FIREWORKS_K2_6_MODEL_ID, FIREWORKS_DEFAULT_MODEL_ID]) {
const resolved = provider.resolveDynamicModel?.(
createProviderDynamicModelContext({
provider: "fireworks",
modelId,
models: [createFireworksDefaultRuntimeModel({ reasoning: false })],
}),
);
expect(resolved?.provider).toBe("fireworks");
expect(resolved?.id).toBe("accounts/fireworks/models/kimi-k2p6");
expect(resolved?.reasoning).toBe(false);
expect(resolved).toBeUndefined();
}
});
it("exposes off-only thinking policy for Fireworks Kimi models", async () => {

View File

@@ -15,11 +15,13 @@ import {
FIREWORKS_DEFAULT_CONTEXT_WINDOW,
FIREWORKS_DEFAULT_MAX_TOKENS,
FIREWORKS_DEFAULT_MODEL_ID,
isFireworksCatalogModelId,
} from "./provider-catalog.js";
import { wrapFireworksProviderStream } from "./stream.js";
import { resolveFireworksThinkingProfile } from "./thinking-policy.js";
const PROVIDER_ID = "fireworks";
function isFireworksGlmModelId(modelId: string): boolean {
const normalized = modelId.trim().toLowerCase();
const lastSegment = normalized.split("/").pop() ?? normalized;
@@ -35,6 +37,11 @@ function resolveFireworksDynamicModel(ctx: ProviderResolveDynamicModelContext) {
if (!modelId) {
return undefined;
}
if (isFireworksCatalogModelId(modelId)) {
return undefined;
}
const isKimiModel = isFireworksKimiModelId(modelId);
const input = resolveFireworksDynamicInput(modelId);

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