Compare commits

...

38 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
96 changed files with 5317 additions and 523 deletions

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -56,6 +56,10 @@ function isCopilotGeminiModelId(modelId: string): boolean {
return /(?:^|[-_.])gemini(?:$|[-_.])/.test(modelId);
}
function isCopilotClaude45ModelId(modelId: string): boolean {
return /^claude-(?:haiku|opus|sonnet)-4[.-]5(?:$|[-.])/.test(modelId);
}
export function resolveCopilotTransportApi(modelId: string): CopilotRuntimeApi {
const normalized = normalizeOptionalLowercaseString(modelId) ?? "";
if (normalized.includes("claude")) {
@@ -71,7 +75,15 @@ export function resolveCopilotModelCompat(
modelId: string,
): ModelDefinitionConfig["compat"] | undefined {
const normalized = normalizeOptionalLowercaseString(modelId) ?? "";
return isCopilotGeminiModelId(normalized) ? { ...COPILOT_CHAT_COMPLETIONS_COMPAT } : undefined;
if (isCopilotGeminiModelId(normalized)) {
return { ...COPILOT_CHAT_COMPLETIONS_COMPAT };
}
// Copilot's Claude 4.5 endpoints reject Anthropic's eager tool extension,
// while current Claude 4.6+ endpoints accept it.
if (isCopilotClaude45ModelId(normalized)) {
return { supportsEagerToolInputStreaming: false };
}
return undefined;
}
function compatSupportsEffort(

View File

@@ -90,8 +90,18 @@ describe("github-copilot model defaults", () => {
const def = buildCopilotModelDefinition("claude-sonnet-4.6");
expect(def.id).toBe("claude-sonnet-4.6");
expect(def.api).toBe("anthropic-messages");
expect(def.compat).toBeUndefined();
});
it.each(["claude-haiku-4.5", "claude-sonnet-4-5"])(
"disables eager tool streaming for Copilot Claude 4.5 model %s",
(modelId) => {
expect(buildCopilotModelDefinition(modelId).compat).toEqual({
supportsEagerToolInputStreaming: false,
});
},
);
it("uses static metadata overrides for gpt-5.5 fallback rows", () => {
const def = buildCopilotModelDefinition("gpt-5.5");
expect(def).toEqual({
@@ -243,6 +253,12 @@ describe("resolveCopilotForwardCompatModel", () => {
expect((result as unknown as Record<string, unknown>).input).toEqual(["text", "image"]);
});
it("disables eager tool streaming for synthetic Copilot Claude 4.5 models", () => {
const result = requireResolvedModel(createMockCtx("claude-haiku-4.5"));
expect(result.api).toBe("anthropic-messages");
expect(result.compat).toEqual({ supportsEagerToolInputStreaming: false });
});
it("creates synthetic Gemini models with Chat Completions compatibility", () => {
const result = requireResolvedModel(createMockCtx("gemini-3.1-pro-preview"));
expect((result as unknown as Record<string, unknown>).api).toBe("openai-completions");
@@ -620,6 +636,7 @@ describe("fetchCopilotModelCatalog", () => {
const opus45 = out.find((m) => m.id === "claude-opus-4-5");
expect(opus45?.thinkingLevelMap).toEqual({ xhigh: null, max: null });
expect(opus45?.compat).toEqual({
supportsEagerToolInputStreaming: false,
supportedReasoningEfforts: ["low", "medium", "high", "max"],
});
});

View File

@@ -10,6 +10,7 @@ import {
runWikiChatGptImport,
runWikiChatGptRollback,
runWikiDoctor,
runWikiOkfImport,
runWikiStatus,
} from "./cli.js";
import type { MemoryWikiPluginConfig } from "./config.js";
@@ -27,6 +28,7 @@ vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({
const { createVault } = createMemoryWikiTestHarness();
let suiteRoot = "";
let caseIndex = 0;
let stdoutWriteMock: ReturnType<typeof vi.fn>;
describe("memory-wiki cli", () => {
beforeAll(async () => {
@@ -41,8 +43,9 @@ describe("memory-wiki cli", () => {
beforeEach(() => {
callGatewayFromCliMock.mockReset();
stdoutWriteMock = vi.fn(() => true);
vi.spyOn(process.stdout, "write").mockImplementation(
(() => true) as typeof process.stdout.write,
stdoutWriteMock as unknown as typeof process.stdout.write,
);
process.exitCode = undefined;
});
@@ -174,6 +177,65 @@ describe("memory-wiki cli", () => {
);
});
it("registers OKF import and searches imported concepts", async () => {
const { rootDir, config } = await createCliVault();
const bundlePath = path.join(rootDir, "okf-bundle");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
await fs.writeFile(path.join(bundlePath, "index.md"), "# Sales OKF\n", "utf8");
await fs.writeFile(
path.join(bundlePath, "tables", "customers.md"),
`---
type: BigQuery Table
title: Customers
---
Customer rows.
`,
"utf8",
);
await fs.writeFile(
path.join(bundlePath, "tables", "orders.md"),
`---
type: BigQuery Table
title: Orders
description: One row per completed order.
---
Orders join to [customers](/tables/customers.md).
`,
"utf8",
);
const program = new Command();
program.name("test");
registerWikiCli(program, config);
await program.parseAsync(["wiki", "okf", "import", bundlePath, "--json"], { from: "user" });
const importOutput = String(stdoutWriteMock.mock.calls.at(-1)?.[0] ?? "");
const importResult = JSON.parse(importOutput) as Awaited<ReturnType<typeof runWikiOkfImport>>;
expect(importResult.importedCount).toBe(2);
expect(importResult.pagePaths).toEqual(
expect.arrayContaining([
expect.stringMatching(/^concepts\/okf-okf-bundle-[0-9a-f]{8}-tables-orders-/),
]),
);
stdoutWriteMock.mockClear();
await program.parseAsync(["wiki", "search", "completed order", "--json"], { from: "user" });
const searchOutput = String(stdoutWriteMock.mock.calls.at(-1)?.[0] ?? "");
const searchResults = JSON.parse(searchOutput) as Array<{ path: string; title: string }>;
expect(searchResults).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: "Orders",
path: expect.stringMatching(/^concepts\/okf-okf-bundle-[0-9a-f]{8}-tables-orders-/),
}),
]),
);
});
it("rejects apply confidence values outside the documented range", async () => {
const { config } = await createCliVault();
const program = new Command();

View File

@@ -33,6 +33,7 @@ import {
runObsidianOpen,
runObsidianSearch,
} from "./obsidian.js";
import { formatOkfImportSummary, importMemoryWikiOkfBundle } from "./okf.js";
import {
getMemoryWikiPage,
searchMemoryWiki,
@@ -88,6 +89,10 @@ type WikiIngestCommandOptions = {
title?: string;
};
type WikiOkfImportCommandOptions = {
json?: boolean;
};
type WikiSearchCommandOptions = {
json?: boolean;
maxResults?: number;
@@ -590,6 +595,24 @@ export async function runWikiIngest(params: {
});
}
export async function runWikiOkfImport(params: {
config: ResolvedMemoryWikiConfig;
bundlePath: string;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
return runWikiCommandWithSummary({
json: params.json,
stdout: params.stdout,
run: () =>
importMemoryWikiOkfBundle({
config: params.config,
bundlePath: params.bundlePath,
}),
render: formatOkfImportSummary,
});
}
export async function runWikiSearch(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
@@ -965,6 +988,16 @@ export function registerWikiCli(
await runWikiIngest({ config, inputPath, title: opts.title, json: opts.json });
});
const okf = wiki.command("okf").description("Import Open Knowledge Format bundles");
okf
.command("import")
.description("Import an unpacked OKF bundle into wiki concept pages")
.argument("<path>", "OKF bundle directory")
.option("--json", "Print JSON")
.action(async (bundlePath: string, opts: WikiOkfImportCommandOptions) => {
await runWikiOkfImport({ config, bundlePath, json: opts.json });
});
addWikiSearchConfigOptions(
wiki
.command("search")

View File

@@ -4,7 +4,7 @@ import path from "node:path";
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
type MemoryWikiLogEntry = {
type: "init" | "ingest" | "compile" | "lint";
type: "init" | "ingest" | "okf-import" | "compile" | "lint";
timestamp: string;
details?: Record<string, unknown>;
};

View File

@@ -0,0 +1,609 @@
// Memory Wiki tests cover Open Knowledge Format import behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parseWikiMarkdown } from "./markdown.js";
import { importMemoryWikiOkfBundle } from "./okf.js";
import { searchMemoryWiki } from "./query.js";
import { createMemoryWikiTestHarness } from "./test-helpers.js";
const { createTempDir, createVault } = createMemoryWikiTestHarness();
function getOnlyPagePath(paths: string[]): string {
expect(paths).toHaveLength(1);
const [pagePath] = paths;
if (!pagePath) {
throw new Error("Expected OKF import to produce one page path.");
}
return pagePath;
}
async function writeOkfBundle(rootDir: string) {
const bundlePath = path.join(rootDir, "sales-okf");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
await fs.mkdir(path.join(bundlePath, "metrics"), { recursive: true });
await fs.writeFile(
path.join(bundlePath, "index.md"),
`---
id: sales-okf
okf_version: "0.1"
---
# Sales Bundle
`,
"utf8",
);
await fs.writeFile(path.join(bundlePath, "log.md"), "# Directory Update Log\n", "utf8");
await fs.writeFile(
path.join(bundlePath, "tables", "customers.md"),
`---
type: BigQuery Table
title: Customers
description: Customer table.
resource: https://console.cloud.google.com/bigquery?p=acme&d=sales&t=customers
tags: [sales, customers]
timestamp: 2026-05-28T00:00:00Z
producer_field:
owner: data
---
# Schema
Customer rows.
`,
"utf8",
);
await fs.writeFile(
path.join(bundlePath, "tables", "orders.md"),
`---
type: BigQuery Table
title: Orders
description: One row per completed order.
tags:
- sales
- orders
---
# Schema
Joined with [Customers](/tables/customers.md) and the [weekly metric](../metrics/weekly-active-users.md).
Titled link to [weekly metric](../metrics/weekly-active-users.md "metric docs").
Inline code keeps \`[customers](/tables/customers.md)\` unchanged.
\`\`\`markdown
[customers](/tables/customers.md)
\`\`\`
External citation stays as [BigQuery](https://cloud.google.com/bigquery).
`,
"utf8",
);
await fs.writeFile(
path.join(bundlePath, "metrics", "weekly-active-users.md"),
`---
type: Metric
title: Weekly Active Users
---
Computed from [orders](../tables/orders.md).
`,
"utf8",
);
await fs.writeFile(
path.join(bundlePath, "tables", "draft.md"),
`---
title: Draft
---
Missing type.
`,
"utf8",
);
return bundlePath;
}
describe("importMemoryWikiOkfBundle", () => {
it("imports OKF concept documents as searchable wiki concept pages", async () => {
const rootDir = await createTempDir("memory-wiki-okf-");
const bundlePath = await writeOkfBundle(rootDir);
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const result = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
expect(result.okfVersion).toBe("0.1");
expect(result.importedCount).toBe(3);
expect(result.skippedCount).toBe(1);
expect(result.warnings[0]).toMatchObject({
code: "missing-type",
path: "tables/draft.md",
});
expect(result.pagePaths).toHaveLength(3);
const repeat = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 5, 0),
});
expect(repeat.importedCount).toBe(3);
expect(repeat.updatedCount).toBe(0);
const ordersPath = result.pagePaths.find((pagePath) => pagePath.includes("orders"));
expect(ordersPath).toBeTruthy();
const ordersRaw = await fs.readFile(path.join(config.vault.path, ordersPath!), "utf8");
const orders = parseWikiMarkdown(ordersRaw);
expect(orders.frontmatter).toMatchObject({
pageType: "concept",
title: "Orders",
sourceType: "okf",
provenanceMode: "okf-import",
okfConceptId: "tables/orders",
okfType: "BigQuery Table",
});
expect(orders.frontmatter.sourceIds).toEqual([
expect.stringMatching(/^source\.okf\.sales-okf$/),
]);
expect(orders.body).toMatch(/\]\(okf-sales-okf-tables-customers-/);
expect(orders.body).toMatch(/\]\(okf-sales-okf-metrics-weekly-active-users-/);
expect(orders.body).toContain('"metric docs"');
expect(orders.body).toContain("`[customers](/tables/customers.md)`");
expect(orders.body).toContain("```markdown\n[customers](/tables/customers.md)\n```");
expect(orders.body).toContain("https://cloud.google.com/bigquery");
const okf = orders.frontmatter.okf as Record<string, unknown>;
expect(okf).toMatchObject({
version: "0.1",
bundleName: "sales-okf",
conceptId: "tables/orders",
sourceRelativePath: "tables/orders.md",
});
expect(orders.frontmatter.relationships).toEqual(
expect.arrayContaining([
expect.objectContaining({
targetPath: expect.stringMatching(/^concepts\/okf-sales-okf-tables-customers-/),
kind: "okf-link",
}),
expect.objectContaining({
targetPath: expect.stringMatching(
/^concepts\/okf-sales-okf-metrics-weekly-active-users-/,
),
kind: "okf-link",
}),
]),
);
const customersPath = result.pagePaths.find((pagePath) => pagePath.includes("customers"));
const customersRaw = await fs.readFile(path.join(config.vault.path, customersPath!), "utf8");
const customers = parseWikiMarkdown(customersRaw);
const customersOkf = customers.frontmatter.okf as Record<string, unknown>;
expect(customersOkf.frontmatter).toMatchObject({
producer_field: { owner: "data" },
});
const searchResults = await searchMemoryWiki({
config,
query: "completed order",
searchCorpus: "wiki",
});
expect(searchResults.map((searchResult) => searchResult.path)).toContain(ordersPath);
});
it("caps generated concept filenames for long OKF concept paths", async () => {
const rootDir = await createTempDir("memory-wiki-okf-long-");
const bundlePath = path.join(rootDir, "long-okf");
const deepSegments = Array.from({ length: 40 }, (_, index) => `segment-${index}`);
const deepDir = path.join(bundlePath, ...deepSegments);
await fs.mkdir(deepDir, { recursive: true });
await fs.writeFile(
path.join(deepDir, "orders.md"),
`---
type: BigQuery Table
title: Long Orders
---
Long concept body.
`,
"utf8",
);
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const result = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
expect(result.importedCount).toBe(1);
const [pagePath] = result.pagePaths;
expect(pagePath).toBeDefined();
if (!pagePath) {
throw new Error("Expected OKF import to produce a page path.");
}
const fileName = path.basename(pagePath);
expect(Buffer.byteLength(fileName)).toBeLessThanOrEqual(255);
await expect(fs.readFile(path.join(config.vault.path, pagePath), "utf8")).resolves.toContain(
"Long concept body.",
);
});
it("namespaces concept pages by bundle so repeated OKF paths do not overwrite", async () => {
const rootDir = await createTempDir("memory-wiki-okf-bundles-");
const firstBundle = path.join(rootDir, "first-bundle");
const secondBundle = path.join(rootDir, "second-bundle");
for (const [bundlePath, title] of [
[firstBundle, "First Customers"],
[secondBundle, "Second Customers"],
] as const) {
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
await fs.writeFile(
path.join(bundlePath, "tables", "customers.md"),
`---
type: BigQuery Table
title: ${title}
---
${title} body.
`,
"utf8",
);
}
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const first = await importMemoryWikiOkfBundle({
config,
bundlePath: firstBundle,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
const second = await importMemoryWikiOkfBundle({
config,
bundlePath: secondBundle,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
const firstPath = getOnlyPagePath(first.pagePaths);
const secondPath = getOnlyPagePath(second.pagePaths);
expect(firstPath).not.toBe(secondPath);
await expect(fs.readFile(path.join(config.vault.path, firstPath), "utf8")).resolves.toContain(
"First Customers body.",
);
await expect(fs.readFile(path.join(config.vault.path, secondPath), "utf8")).resolves.toContain(
"Second Customers body.",
);
});
it("removes stale concept pages when an OKF bundle drops a concept", async () => {
const rootDir = await createTempDir("memory-wiki-okf-remove-");
const bundlePath = path.join(rootDir, "removing-okf");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
const customersPath = path.join(bundlePath, "tables", "customers.md");
const ordersPath = path.join(bundlePath, "tables", "orders.md");
await fs.writeFile(
customersPath,
`---
type: BigQuery Table
title: Customers
---
Customer body.
`,
"utf8",
);
await fs.writeFile(
ordersPath,
`---
type: BigQuery Table
title: Orders
---
Order body.
`,
"utf8",
);
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const first = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
const stalePagePath = first.pagePaths.find((pagePath) => pagePath.includes("orders"));
expect(stalePagePath).toBeDefined();
if (!stalePagePath) {
throw new Error("Expected initial OKF import to include orders.");
}
await fs.rm(ordersPath);
const second = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
expect(second.importedCount).toBe(1);
expect(second.removedCount).toBe(1);
expect(second.removedPagePaths).toEqual([stalePagePath]);
await expect(fs.stat(path.join(config.vault.path, stalePagePath))).rejects.toThrow();
const results = await searchMemoryWiki({
config,
query: "Order body",
searchCorpus: "wiki",
});
expect(results).toHaveLength(0);
});
it("does not prune existing pages when current OKF scan has invalid concepts", async () => {
const rootDir = await createTempDir("memory-wiki-okf-invalid-");
const bundlePath = path.join(rootDir, "invalid-okf");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
const customersPath = path.join(bundlePath, "tables", "customers.md");
await fs.writeFile(
customersPath,
`---
type: BigQuery Table
title: Customers
---
Customer body.
`,
"utf8",
);
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const first = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
const pagePath = getOnlyPagePath(first.pagePaths);
await fs.writeFile(
customersPath,
`---
title: Customers
---
Temporarily invalid body.
`,
"utf8",
);
const second = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
expect(second.importedCount).toBe(0);
expect(second.skippedCount).toBe(1);
expect(second.removedCount).toBe(0);
await expect(fs.readFile(path.join(config.vault.path, pagePath), "utf8")).resolves.toContain(
"Customer body.",
);
});
it("detects body-only changes on timestamp-shaped markdown lines", async () => {
const rootDir = await createTempDir("memory-wiki-okf-body-timestamp-");
const bundlePath = path.join(rootDir, "body-timestamp-okf");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
const conceptPath = path.join(bundlePath, "tables", "events.md");
await fs.writeFile(
conceptPath,
`---
type: BigQuery Table
title: Events
---
updatedAt: 2026-06-12
`,
"utf8",
);
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const first = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
const pagePath = getOnlyPagePath(first.pagePaths);
await fs.writeFile(
conceptPath,
`---
type: BigQuery Table
title: Events
---
updatedAt: 2026-06-13
`,
"utf8",
);
const second = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 13, 10, 0, 0),
});
expect(second.updatedCount).toBe(1);
await expect(fs.readFile(path.join(config.vault.path, pagePath), "utf8")).resolves.toContain(
"updatedAt: 2026-06-13",
);
});
it("rewrites percent-encoded OKF markdown links and preserves suffixes", async () => {
const rootDir = await createTempDir("memory-wiki-okf-encoded-link-");
const bundlePath = path.join(rootDir, "encoded-okf");
await fs.mkdir(bundlePath, { recursive: true });
await fs.writeFile(
path.join(bundlePath, "BigQuery Table.md"),
`---
type: BigQuery Table
title: BigQuery Table
---
Table body.
`,
"utf8",
);
await fs.writeFile(
path.join(bundlePath, "links.md"),
`---
type: Concept
title: Links
---
See [table](BigQuery%20Table.md?view=compact#columns).
`,
"utf8",
);
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const result = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
const linksPath = result.pagePaths.find((pagePath) => pagePath.includes("links"));
expect(linksPath).toBeDefined();
if (!linksPath) {
throw new Error("Expected links page to be imported.");
}
await expect(fs.readFile(path.join(config.vault.path, linksPath), "utf8")).resolves.toMatch(
/\[table\]\(okf-encoded-okf-[0-9a-f]{8}-bigquery-table-[^)]+\.md\?view=compact#columns\)/,
);
});
it("imports OKF concept frontmatter with CRLF line endings", async () => {
const rootDir = await createTempDir("memory-wiki-okf-crlf-");
const bundlePath = path.join(rootDir, "crlf-okf");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
await fs.writeFile(
path.join(bundlePath, "tables", "events.md"),
[
"---",
"type: BigQuery Table",
"title: Events",
"---",
"",
"Windows-flavored frontmatter.",
"",
].join("\r\n"),
"utf8",
);
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const result = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
expect(result.importedCount).toBe(1);
expect(result.skippedCount).toBe(0);
await expect(
fs.readFile(path.join(config.vault.path, getOnlyPagePath(result.pagePaths)), "utf8"),
).resolves.toContain("Windows-flavored frontmatter.");
});
it("refuses to write imported OKF concept pages through symlinks", async () => {
const rootDir = await createTempDir("memory-wiki-okf-symlink-");
const bundlePath = path.join(rootDir, "safe-okf");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
const conceptPath = path.join(bundlePath, "tables", "customers.md");
await fs.writeFile(
conceptPath,
`---
type: BigQuery Table
title: Customers
---
Original body.
`,
"utf8",
);
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const first = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
const pagePath = getOnlyPagePath(first.pagePaths);
const pageAbsolutePath = path.join(config.vault.path, pagePath);
const externalTarget = path.join(rootDir, "outside.md");
await fs.writeFile(externalTarget, "external target\n", "utf8");
await fs.rm(pageAbsolutePath);
await fs.symlink(externalTarget, pageAbsolutePath);
await fs.writeFile(
conceptPath,
`---
type: BigQuery Table
title: Customers
---
Updated body.
`,
"utf8",
);
await expect(
importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 11, 0, 0),
}),
).rejects.toThrow("through symlink");
await expect(fs.readFile(externalTarget, "utf8")).resolves.toBe("external target\n");
});
it("refuses to import OKF concept files through hardlinks", async () => {
const rootDir = await createTempDir("memory-wiki-okf-hardlink-");
const bundlePath = path.join(rootDir, "hardlink-okf");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
const externalSource = path.join(rootDir, "outside.md");
await fs.writeFile(
externalSource,
`---
type: BigQuery Table
title: Private
---
private body
`,
"utf8",
);
await fs.link(externalSource, path.join(bundlePath, "tables", "private.md"));
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const result = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
expect(result.importedCount).toBe(0);
expect(result.skippedCount).toBe(1);
expect(result.warnings[0]).toMatchObject({
code: "unreadable-entry",
path: "tables/private.md",
});
});
});

View File

@@ -0,0 +1,746 @@
// Memory Wiki plugin module implements Open Knowledge Format import behavior.
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { FsSafeError, root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
import {
normalizeOptionalString,
normalizeSingleOrTrimmedStringList,
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { compileMemoryWikiVault } from "./compile.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { appendMemoryWikiLog } from "./log.js";
import {
createWikiPageFilename,
parseWikiMarkdown,
renderWikiMarkdown,
slugifyWikiSegment,
WIKI_RELATED_END_MARKER,
WIKI_RELATED_START_MARKER,
} from "./markdown.js";
import { resolveMemoryWikiTimestamp } from "./time.js";
import { initializeMemoryWikiVault } from "./vault.js";
const OKF_RESERVED_FILENAMES = new Set(["index.md", "log.md"]);
const OKF_MARKDOWN_LINK_PATTERN = /(!?)\[([^\]]*)\]\(([^)]+)\)/g;
const OKF_FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})/;
const OKF_RELATED_SECTION_PATTERN = new RegExp(
`\\n+## Related\\n${WIKI_RELATED_START_MARKER}[\\s\\S]*?${WIKI_RELATED_END_MARKER}\\n?`,
"g",
);
const OKF_VOLATILE_TIMESTAMP_LINE_PATTERN = /^(?:importedAt|updatedAt): .*\n/gm;
const OKF_HASH_CHARS = 8;
type FileStatLike = {
isFile?: unknown;
nlink?: unknown;
};
type OkfConceptDocument = {
conceptId: string;
relativePath: string;
absolutePath: string;
frontmatter: Record<string, unknown>;
body: string;
type: string;
title: string;
description?: string;
resource?: string;
tags: string[];
timestamp?: string;
};
type OkfImportedPage = {
conceptId: string;
sourcePath: string;
pageId: string;
pagePath: string;
title: string;
created: boolean;
};
export type ImportMemoryWikiOkfWarning = {
code: "invalid-concept" | "missing-type" | "unreadable-entry";
path: string;
message: string;
};
export type ImportMemoryWikiOkfResult = {
bundlePath: string;
bundleName: string;
okfVersion?: string;
importedCount: number;
updatedCount: number;
removedCount: number;
skippedCount: number;
pagePaths: string[];
removedPagePaths: string[];
warnings: ImportMemoryWikiOkfWarning[];
indexUpdatedFiles: string[];
};
function toPosixPath(value: string): string {
return value.split(path.sep).join("/");
}
function trimMarkdownExtension(value: string): string {
return value.replace(/\.md$/i, "");
}
function isRegularFileStat(value: unknown): value is FileStatLike & { nlink: number } {
if (!value || typeof value !== "object") {
return false;
}
const stat = value as FileStatLike;
const isFile =
typeof stat.isFile === "function"
? (stat.isFile as () => boolean).call(stat)
: stat.isFile === true;
return isFile && typeof stat.nlink === "number";
}
type OkfBundleMetadata = {
key: string;
version?: string;
};
function createOkfBundleKey(params: {
rootFrontmatter: Record<string, unknown>;
bundleName: string;
bundlePath: string;
}): string {
const producerId =
normalizeOptionalString(params.rootFrontmatter.id) ??
normalizeOptionalString(params.rootFrontmatter.okf_id);
if (producerId) {
return slugifyWikiSegment(producerId);
}
const label =
normalizeOptionalString(params.rootFrontmatter.name) ??
normalizeOptionalString(params.rootFrontmatter.title) ??
params.bundleName;
const hash = createHash("sha1").update(params.bundlePath).digest("hex").slice(0, OKF_HASH_CHARS);
return `${slugifyWikiSegment(label)}-${hash}`;
}
function createOkfPageStem(bundleKey: string, conceptId: string): string {
const slug = slugifyWikiSegment(conceptId.replace(/\//g, "-"));
const hash = createHash("sha1").update(conceptId).digest("hex").slice(0, OKF_HASH_CHARS);
return `okf-${bundleKey}-${slug}-${hash}`;
}
function createOkfPageIdentity(
bundleKey: string,
conceptId: string,
): { pageId: string; pagePath: string } {
const fileName = createWikiPageFilename(createOkfPageStem(bundleKey, conceptId));
const stem = trimMarkdownExtension(fileName);
return {
pageId: `concept.${stem}`,
pagePath: `concepts/${fileName}`,
};
}
async function collectOkfMarkdownFiles(
rootDir: string,
warnings: ImportMemoryWikiOkfWarning[],
): Promise<string[]> {
async function walk(relativeDir: string): Promise<string[]> {
const absoluteDir = path.join(rootDir, relativeDir);
const entries = await fs.readdir(absoluteDir, { withFileTypes: true }).catch((err: unknown) => {
warnings.push({
code: "unreadable-entry",
path: toPosixPath(relativeDir) || ".",
message: err instanceof Error ? err.message : "Unable to read OKF directory.",
});
return [];
});
const files: string[] = [];
for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
if (entry.name === ".git" || entry.name === "node_modules") {
continue;
}
const relativePath = path.join(relativeDir, entry.name);
if (entry.isDirectory()) {
files.push(...(await walk(relativePath)));
continue;
}
if (entry.isFile() && entry.name.endsWith(".md")) {
files.push(relativePath);
}
}
return files;
}
return (await walk("")).map(toPosixPath).toSorted((left, right) => left.localeCompare(right));
}
function parseOkfMarkdown(
content: string,
relativePath: string,
): {
frontmatter: Record<string, unknown>;
body: string;
warning?: ImportMemoryWikiOkfWarning;
} {
const normalizedContent = content.replace(/\r\n/g, "\n");
try {
return parseWikiMarkdown(normalizedContent);
} catch (err) {
return {
frontmatter: {},
body: normalizedContent,
warning: {
code: "invalid-concept",
path: relativePath,
message: err instanceof Error ? err.message : "Unable to parse OKF frontmatter.",
},
};
}
}
async function readOkfTextFile(params: {
bundlePath: string;
relativePath: string;
warnings: ImportMemoryWikiOkfWarning[];
}): Promise<string | null> {
const root = await fsRoot(params.bundlePath);
const stat = await root.stat(params.relativePath).catch((err: unknown) => {
params.warnings.push({
code: "unreadable-entry",
path: params.relativePath,
message: err instanceof Error ? err.message : "Unable to read OKF concept.",
});
return null;
});
if (!stat) {
return null;
}
if (!isRegularFileStat(stat)) {
params.warnings.push({
code: "unreadable-entry",
path: params.relativePath,
message: "Refusing to import OKF concept through non-regular or hardlinked file.",
});
return null;
}
return await root.readText(params.relativePath).catch((err: unknown) => {
params.warnings.push({
code: "unreadable-entry",
path: params.relativePath,
message: err instanceof Error ? err.message : "Unable to read OKF concept.",
});
return null;
});
}
function deriveOkfTitle(relativePath: string, frontmatter: Record<string, unknown>): string {
return (
normalizeOptionalString(frontmatter.title) ??
path.posix.basename(relativePath, ".md").replace(/[-_]+/g, " ").trim() ??
trimMarkdownExtension(relativePath)
);
}
function normalizeOkfConcept(params: {
bundlePath: string;
relativePath: string;
content: string;
}): { concept?: OkfConceptDocument; warning?: ImportMemoryWikiOkfWarning } {
const parsed = parseOkfMarkdown(params.content, params.relativePath);
if (parsed.warning) {
return { warning: parsed.warning };
}
const type = normalizeOptionalString(parsed.frontmatter.type);
if (!type) {
return {
warning: {
code: "missing-type",
path: params.relativePath,
message: "OKF concept is missing required non-empty type frontmatter.",
},
};
}
const conceptId = trimMarkdownExtension(params.relativePath);
const timestamp = normalizeOptionalString(parsed.frontmatter.timestamp);
return {
concept: {
conceptId,
relativePath: params.relativePath,
absolutePath: path.join(params.bundlePath, params.relativePath),
frontmatter: parsed.frontmatter,
body: parsed.body,
type,
title: deriveOkfTitle(params.relativePath, parsed.frontmatter),
...(normalizeOptionalString(parsed.frontmatter.description)
? { description: normalizeOptionalString(parsed.frontmatter.description) }
: {}),
...(normalizeOptionalString(parsed.frontmatter.resource)
? { resource: normalizeOptionalString(parsed.frontmatter.resource) }
: {}),
tags: normalizeSingleOrTrimmedStringList(parsed.frontmatter.tags),
...(timestamp ? { timestamp } : {}),
},
};
}
function splitMarkdownLinkDestination(target: string): {
destination: string;
titleSuffix: string;
} {
const trimmed = target.trim();
if (trimmed.startsWith("<")) {
const end = trimmed.indexOf(">");
if (end > 0) {
return {
destination: trimmed.slice(1, end),
titleSuffix: trimmed.slice(end + 1),
};
}
}
const match = trimmed.match(/^(\S+)(\s+[\s\S]+)?$/);
return {
destination: match?.[1] ?? trimmed,
titleSuffix: match?.[2] ?? "",
};
}
function resolveOkfMarkdownTarget(sourceRelativePath: string, target: string): string | null {
const { destination } = splitMarkdownLinkDestination(target);
const trimmed = destination.trim();
if (!trimmed || trimmed.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(trimmed)) {
return null;
}
const rawTargetWithoutSuffix = trimmed.split("#")[0]?.split("?")[0]?.replace(/\\/g, "/").trim();
const targetWithoutSuffix = safeDecodeOkfLinkPath(rawTargetWithoutSuffix);
if (!targetWithoutSuffix || !targetWithoutSuffix.endsWith(".md")) {
return null;
}
const normalized = targetWithoutSuffix.startsWith("/")
? path.posix.normalize(targetWithoutSuffix.slice(1))
: path.posix.normalize(
path.posix.join(path.posix.dirname(sourceRelativePath), targetWithoutSuffix),
);
const conceptId = trimMarkdownExtension(normalized);
return conceptId.startsWith("../") ? null : conceptId;
}
function safeDecodeOkfLinkPath(value: string | undefined): string {
if (!value) {
return "";
}
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function getMarkdownDestinationSuffix(destination: string): string {
const queryIndex = destination.indexOf("?");
const fragmentIndex = destination.indexOf("#");
const suffixIndex = queryIndex === -1
? fragmentIndex
: fragmentIndex === -1
? queryIndex
: Math.min(queryIndex, fragmentIndex);
return suffixIndex === -1 ? "" : destination.slice(suffixIndex);
}
function rewriteOkfMarkdownLinks(params: {
body: string;
sourcePagePath: string;
sourceRelativePath: string;
pageByConceptId: Map<string, { pageId: string; pagePath: string; title: string }>;
}): { body: string; linkedConceptIds: string[] } {
const linkedConceptIds: string[] = [];
const rewriteLinks = (markdown: string) =>
markdown.replace(
OKF_MARKDOWN_LINK_PATTERN,
(match, imagePrefix: string, label: string, rawTarget: string) => {
const conceptId = resolveOkfMarkdownTarget(params.sourceRelativePath, rawTarget);
if (!conceptId) {
return match;
}
const target = params.pageByConceptId.get(conceptId);
if (!target) {
return match;
}
linkedConceptIds.push(conceptId);
const { destination, titleSuffix } = splitMarkdownLinkDestination(rawTarget);
const relativeTarget = path.posix.relative(
path.posix.dirname(params.sourcePagePath),
target.pagePath,
);
const suffix = getMarkdownDestinationSuffix(destination);
return `${imagePrefix}[${label}](${relativeTarget}${suffix}${titleSuffix})`;
},
);
const body = rewriteMarkdownOutsideCode(params.body, rewriteLinks);
return { body, linkedConceptIds: uniqueStrings(linkedConceptIds) };
}
function rewriteMarkdownLineOutsideInlineCode(
line: string,
rewriteLinks: (markdown: string) => string,
): string {
let result = "";
let cursor = 0;
while (cursor < line.length) {
const codeStart = line.indexOf("`", cursor);
if (codeStart === -1) {
result += rewriteLinks(line.slice(cursor));
break;
}
result += rewriteLinks(line.slice(cursor, codeStart));
const delimiter = line.slice(codeStart).match(/^`+/)?.[0] ?? "`";
const codeEnd = line.indexOf(delimiter, codeStart + delimiter.length);
if (codeEnd === -1) {
result += line.slice(codeStart);
break;
}
result += line.slice(codeStart, codeEnd + delimiter.length);
cursor = codeEnd + delimiter.length;
}
return result;
}
function rewriteMarkdownOutsideCode(
markdown: string,
rewriteLinks: (markdown: string) => string,
): string {
const lines = markdown.split(/(\n)/);
let inFence = false;
let fenceDelimiter = "";
return lines
.map((line) => {
if (line === "\n") {
return line;
}
const fenceMatch = line.match(OKF_FENCE_PATTERN);
if (fenceMatch) {
const delimiter = fenceMatch[1] ?? "";
const closesFence =
inFence &&
delimiter.startsWith(fenceDelimiter[0] ?? "") &&
delimiter.length >= fenceDelimiter.length;
const opensFence = !inFence;
if (opensFence) {
inFence = true;
fenceDelimiter = delimiter;
} else if (closesFence) {
inFence = false;
fenceDelimiter = "";
}
return line;
}
return inFence ? line : rewriteMarkdownLineOutsideInlineCode(line, rewriteLinks);
})
.join("");
}
function normalizeOkfRenderedPageForComparison(content: string): string {
const withoutRelated = content.replace(OKF_RELATED_SECTION_PATTERN, "\n");
const frontmatterMatch = withoutRelated.match(/^---\n([\s\S]*?)\n---\n?/);
if (!frontmatterMatch) {
return withoutRelated.trimEnd();
}
const normalizedFrontmatter =
frontmatterMatch[1]?.replace(OKF_VOLATILE_TIMESTAMP_LINE_PATTERN, "") ?? "";
const frontmatterBody = normalizedFrontmatter.endsWith("\n")
? normalizedFrontmatter
: `${normalizedFrontmatter}\n`;
return `---\n${frontmatterBody}---\n${withoutRelated.slice(frontmatterMatch[0].length)}`.trimEnd();
}
async function writeOkfConceptPage(params: {
vaultRoot: string;
pagePath: string;
content: string;
}): Promise<{ changed: boolean; created: boolean }> {
const vault = await fsRoot(params.vaultRoot);
const pageStat = await vault.stat(params.pagePath).catch((error: unknown) => {
if (
error instanceof FsSafeError &&
(error.code === "not-found" || error.code === "path-alias")
) {
return null;
}
throw error;
});
const existing = pageStat ? await vault.readText(params.pagePath).catch(() => "") : "";
if (
existing === params.content ||
normalizeOkfRenderedPageForComparison(existing) ===
normalizeOkfRenderedPageForComparison(params.content)
) {
return { changed: false, created: !pageStat };
}
try {
if (isRegularFileStat(pageStat) && pageStat.nlink > 1) {
await vault.remove(params.pagePath);
}
await vault.write(params.pagePath, params.content);
} catch (error) {
if (error instanceof FsSafeError) {
if (error.code !== "symlink" && error.code !== "path-alias") {
throw new Error(
`Refusing to write OKF concept page (${error.code}): ${params.pagePath}: ${error.message}`,
{ cause: error },
);
}
throw new Error(`Refusing to write OKF concept page through symlink: ${params.pagePath}`, {
cause: error,
});
}
throw error;
}
return { changed: true, created: !pageStat };
}
async function removeStaleOkfConceptPages(params: {
vaultRoot: string;
bundleKey: string;
currentPagePaths: Set<string>;
}): Promise<string[]> {
const vault = await fsRoot(params.vaultRoot);
const conceptsDir = path.join(params.vaultRoot, "concepts");
const entries = await fs.readdir(conceptsDir, { withFileTypes: true }).catch(() => []);
const removedPagePaths: string[] = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "index.md") {
continue;
}
const pagePath = `concepts/${entry.name}`;
if (params.currentPagePaths.has(pagePath)) {
continue;
}
const raw = await vault.readText(pagePath).catch(() => "");
const parsed = parseWikiMarkdown(raw);
const okf = parsed.frontmatter.okf;
if (
okf &&
typeof okf === "object" &&
!Array.isArray(okf) &&
(okf as Record<string, unknown>).bundleKey === params.bundleKey
) {
await vault.remove(pagePath);
removedPagePaths.push(pagePath);
}
}
return removedPagePaths;
}
function readRootOkfMetadata(params: {
rootIndex: string | undefined;
bundleName: string;
bundlePath: string;
}): OkfBundleMetadata {
if (!params.rootIndex) {
return {
key: createOkfBundleKey({
rootFrontmatter: {},
bundleName: params.bundleName,
bundlePath: params.bundlePath,
}),
};
}
const parsed = parseOkfMarkdown(params.rootIndex, "index.md");
return {
key: createOkfBundleKey({
rootFrontmatter: parsed.frontmatter,
bundleName: params.bundleName,
bundlePath: params.bundlePath,
}),
...(normalizeOptionalString(parsed.frontmatter.okf_version)
? { version: normalizeOptionalString(parsed.frontmatter.okf_version) }
: {}),
};
}
function formatOkfImportSummary(result: ImportMemoryWikiOkfResult): string {
return `Imported ${result.importedCount} OKF concept${result.importedCount === 1 ? "" : "s"} from ${result.bundlePath} into memory wiki. Updated ${result.updatedCount}; removed ${result.removedCount}; skipped ${result.skippedCount}; refreshed ${result.indexUpdatedFiles.length} index file${result.indexUpdatedFiles.length === 1 ? "" : "s"}.`;
}
export { formatOkfImportSummary };
export async function importMemoryWikiOkfBundle(params: {
config: ResolvedMemoryWikiConfig;
bundlePath: string;
nowMs?: number;
}): Promise<ImportMemoryWikiOkfResult> {
await initializeMemoryWikiVault(params.config, { nowMs: params.nowMs });
const bundlePath = path.resolve(params.bundlePath);
const stat = await fs.stat(bundlePath);
if (!stat.isDirectory()) {
throw new Error("wiki okf import expects an unpacked OKF bundle directory.");
}
const warnings: ImportMemoryWikiOkfWarning[] = [];
const markdownFiles = await collectOkfMarkdownFiles(bundlePath, warnings);
const concepts: OkfConceptDocument[] = [];
let rootIndexContent: string | undefined;
for (const relativePath of markdownFiles) {
if (relativePath === "index.md") {
rootIndexContent =
(await readOkfTextFile({ bundlePath, relativePath, warnings })) ?? undefined;
}
if (OKF_RESERVED_FILENAMES.has(path.posix.basename(relativePath))) {
continue;
}
const content = await readOkfTextFile({ bundlePath, relativePath, warnings });
if (content === null) {
continue;
}
const normalized = normalizeOkfConcept({ bundlePath, relativePath, content });
if (normalized.warning) {
warnings.push(normalized.warning);
continue;
}
if (normalized.concept) {
concepts.push(normalized.concept);
}
}
const timestamp = resolveMemoryWikiTimestamp(params.nowMs);
const bundleName = path.basename(bundlePath);
const bundleMetadata = readRootOkfMetadata({
rootIndex: rootIndexContent,
bundleName,
bundlePath,
});
const bundleKey = bundleMetadata.key;
const pageByConceptId = new Map<string, { pageId: string; pagePath: string; title: string }>();
for (const concept of concepts) {
pageByConceptId.set(concept.conceptId, {
...createOkfPageIdentity(bundleKey, concept.conceptId),
title: concept.title,
});
}
const importedPages: OkfImportedPage[] = [];
let updatedCount = 0;
await fs.mkdir(path.join(params.config.vault.path, "concepts"), { recursive: true });
for (const concept of concepts.toSorted((left, right) =>
left.conceptId.localeCompare(right.conceptId),
)) {
const page = pageByConceptId.get(concept.conceptId);
if (!page) {
continue;
}
const rewritten = rewriteOkfMarkdownLinks({
body: concept.body,
sourcePagePath: page.pagePath,
sourceRelativePath: concept.relativePath,
pageByConceptId,
});
const relationships = rewritten.linkedConceptIds.flatMap((conceptId) => {
const target = pageByConceptId.get(conceptId);
return target
? [
{
targetId: target.pageId,
targetPath: target.pagePath,
targetTitle: target.title,
kind: "okf-link",
evidenceKind: "okf-markdown-link",
},
]
: [];
});
const frontmatter = {
pageType: "concept",
id: page.pageId,
title: concept.title,
sourceType: "okf",
provenanceMode: "okf-import",
sourcePath: concept.absolutePath,
okfConceptId: concept.conceptId,
okfType: concept.type,
sourceIds: [`source.okf.${bundleKey}`],
importedAt: timestamp,
updatedAt: concept.timestamp ?? timestamp,
status: "active",
...(concept.description ? { description: concept.description } : {}),
...(concept.resource ? { resource: concept.resource } : {}),
...(concept.tags.length > 0 ? { tags: concept.tags } : {}),
...(concept.timestamp ? { okfTimestamp: concept.timestamp } : {}),
...(relationships.length > 0 ? { relationships } : {}),
okf: {
...(bundleMetadata.version ? { version: bundleMetadata.version } : {}),
bundleName,
bundleKey,
conceptId: concept.conceptId,
sourceRelativePath: concept.relativePath,
frontmatter: concept.frontmatter,
},
};
const writeResult = await writeOkfConceptPage({
vaultRoot: params.config.vault.path,
pagePath: page.pagePath,
content: renderWikiMarkdown({
frontmatter,
body: rewritten.body,
}),
});
if (!writeResult.created && writeResult.changed) {
updatedCount++;
}
importedPages.push({
conceptId: concept.conceptId,
sourcePath: concept.absolutePath,
pageId: page.pageId,
pagePath: page.pagePath,
title: concept.title,
created: writeResult.created,
});
}
const currentPagePaths = new Set(importedPages.map((page) => page.pagePath));
const removedPagePaths =
warnings.length === 0
? await removeStaleOkfConceptPages({
vaultRoot: params.config.vault.path,
bundleKey,
currentPagePaths,
})
: [];
await appendMemoryWikiLog(params.config.vault.path, {
type: "okf-import",
timestamp,
details: {
bundlePath,
bundleName,
importedCount: importedPages.length,
updatedCount,
removedCount: removedPagePaths.length,
skippedCount: warnings.length,
pagePaths: importedPages.map((page) => page.pagePath),
removedPagePaths,
},
});
const compile = await compileMemoryWikiVault(params.config);
return {
bundlePath,
bundleName,
...(bundleMetadata.version ? { okfVersion: bundleMetadata.version } : {}),
importedCount: importedPages.length,
updatedCount,
removedCount: removedPagePaths.length,
skippedCount: warnings.length,
pagePaths: importedPages.map((page) => page.pagePath),
removedPagePaths,
warnings,
indexUpdatedFiles: compile.updatedFiles,
};
}

View File

@@ -11,6 +11,24 @@ import {
expectUnifiedModelCatalogProviderRegistration,
} from "openclaw/plugin-sdk/provider-test-contracts";
import { describe, expect, it, vi } from "vitest";
const { getOpenRouterModelCapabilitiesMock, loadOpenRouterModelCapabilitiesMock } = vi.hoisted(
() => ({
getOpenRouterModelCapabilitiesMock: vi.fn(),
loadOpenRouterModelCapabilitiesMock: vi.fn(async () => {}),
}),
);
vi.mock("openclaw/plugin-sdk/provider-stream-family", async (importOriginal) => {
const actual =
await importOriginal<typeof import("openclaw/plugin-sdk/provider-stream-family")>();
return {
...actual,
getOpenRouterModelCapabilities: getOpenRouterModelCapabilitiesMock,
loadOpenRouterModelCapabilities: loadOpenRouterModelCapabilitiesMock,
};
});
import openrouterPlugin from "./index.js";
import {
buildOpenrouterProvider,
@@ -204,6 +222,59 @@ describe("openrouter provider hooks", () => {
expect(buildOpenrouterProvider().models?.map((model) => model.id)).not.toContain("auto");
});
it("normalizes OpenRouter API ids before capability loading and lookup", async () => {
getOpenRouterModelCapabilitiesMock.mockReset();
loadOpenRouterModelCapabilitiesMock.mockClear();
getOpenRouterModelCapabilitiesMock.mockReturnValue({
name: "Claude Sonnet 4.6",
reasoning: true,
input: ["text", "image"],
supportsTools: true,
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 64_000,
});
const provider = await registerSingleProviderPlugin(openrouterPlugin);
const modelId = "openrouter/anthropic/claude-sonnet-4.6";
const context = {
provider: "openrouter",
modelId,
modelRegistry: { find: vi.fn(() => null) },
} as never;
await provider.prepareDynamicModel?.(context);
const model = provider.resolveDynamicModel?.(context);
expect(loadOpenRouterModelCapabilitiesMock).toHaveBeenCalledWith("anthropic/claude-sonnet-4.6");
expect(getOpenRouterModelCapabilitiesMock).toHaveBeenCalledWith("anthropic/claude-sonnet-4.6");
expect(model).toMatchObject({
id: modelId,
name: "Claude Sonnet 4.6",
reasoning: true,
input: ["text", "image"],
compat: { supportsTools: true },
contextWindow: 200_000,
maxTokens: 64_000,
});
});
it("keeps native OpenRouter namespace ids for capability lookup", async () => {
getOpenRouterModelCapabilitiesMock.mockReset();
loadOpenRouterModelCapabilitiesMock.mockClear();
const provider = await registerSingleProviderPlugin(openrouterPlugin);
const context = {
provider: "openrouter",
modelId: "openrouter/auto",
modelRegistry: { find: vi.fn(() => null) },
} as never;
await provider.prepareDynamicModel?.(context);
provider.resolveDynamicModel?.(context);
expect(loadOpenRouterModelCapabilitiesMock).toHaveBeenCalledWith("openrouter/auto");
expect(getOpenRouterModelCapabilitiesMock).toHaveBeenCalledWith("openrouter/auto");
});
it("does not include retired stealth models in the bundled catalog", () => {
const modelIds = buildOpenrouterProvider().models?.map((model) => model.id) ?? [];
expect(modelIds).not.toContain("openrouter/hunter-alpha");
@@ -389,6 +460,61 @@ describe("openrouter provider hooks", () => {
},
} as never);
expect(normalizedHunterModel?.reasoning).toBe(false);
expect(normalizedHunterModel?.id).toBe("openrouter/hunter-alpha");
const normalizedAnthropicModel = provider.normalizeResolvedModel?.({
provider: "openrouter",
model: {
provider: "openrouter",
id: "openrouter/anthropic/claude-sonnet-4.6",
name: "anthropic/claude-sonnet-4.6",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8192,
},
} as never);
expect(normalizedAnthropicModel?.id).toBe("anthropic/claude-sonnet-4.6");
expect(
provider.normalizeResolvedModel?.({
provider: "openrouter",
modelId: "openrouter/auto",
model: {
provider: "openrouter",
id: "openrouter/auto",
name: "OpenRouter Auto",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8192,
},
} as never),
).toBeUndefined();
const normalizedDuplicatedAutoModel = provider.normalizeResolvedModel?.({
provider: "openrouter",
modelId: "openrouter/openrouter/auto",
model: {
provider: "openrouter",
id: "openrouter/openrouter/auto",
name: "OpenRouter Auto",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8192,
},
} as never);
expect(normalizedDuplicatedAutoModel?.id).toBe("openrouter/auto");
expect(
provider.normalizeTransport?.({

View File

@@ -17,7 +17,7 @@ import {
} from "openclaw/plugin-sdk/provider-stream-family";
import { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js";
import { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { isOpenRouterMistralModelId } from "./models.js";
import { isOpenRouterMistralModelId, normalizeOpenRouterApiModelId } from "./models.js";
import { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
import { createOpenRouterOAuthAuthMethod } from "./oauth.js";
import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js";
@@ -51,15 +51,18 @@ const OPENROUTER_CACHE_TTL_MODEL_PREFIXES = [
function normalizeOpenRouterResolvedModel<T extends ProviderRuntimeModel>(model: T): T | undefined {
const normalizedBaseUrl = normalizeOpenRouterBaseUrl(model.baseUrl);
const normalizedId = normalizeOpenRouterApiModelId(model.id);
const reasoning = isOpenRouterProxyReasoningUnsupportedModel(model.id) ? false : model.reasoning;
if (
(!normalizedBaseUrl || normalizedBaseUrl === model.baseUrl) &&
(!normalizedId || normalizedId === model.id) &&
reasoning === model.reasoning
) {
return undefined;
}
return {
...model,
...(normalizedId ? { id: normalizedId } : {}),
...(normalizedBaseUrl ? { baseUrl: normalizedBaseUrl } : {}),
reasoning,
};
@@ -73,7 +76,8 @@ export default definePluginEntry({
function buildDynamicOpenRouterModel(
ctx: ProviderResolveDynamicModelContext,
): ProviderRuntimeModel {
const capabilities = getOpenRouterModelCapabilities(ctx.modelId);
const apiModelId = normalizeOpenRouterApiModelId(ctx.modelId) ?? ctx.modelId;
const capabilities = getOpenRouterModelCapabilities(apiModelId);
return {
id: ctx.modelId,
name: capabilities?.name ?? ctx.modelId,
@@ -166,7 +170,9 @@ export default definePluginEntry({
},
resolveDynamicModel: (ctx) => buildDynamicOpenRouterModel(ctx),
prepareDynamicModel: async (ctx) => {
await loadOpenRouterModelCapabilities(ctx.modelId);
await loadOpenRouterModelCapabilities(
normalizeOpenRouterApiModelId(ctx.modelId) ?? ctx.modelId,
);
},
normalizeConfig: ({ providerConfig }) => {
const normalizedBaseUrl = normalizeOpenRouterBaseUrl(providerConfig.baseUrl);

View File

@@ -12,13 +12,30 @@ const OPENROUTER_MISTRAL_MODEL_PREFIXES = [
"pixtral-",
"voxtral-",
] as const;
const OPENROUTER_MODEL_PREFIX = "openrouter/";
export function normalizeOpenRouterModelId(modelId: unknown): string | undefined {
if (typeof modelId !== "string") {
return undefined;
}
const normalized = normalizeLowercaseStringOrEmpty(modelId);
return normalized.startsWith("openrouter/") ? normalized.slice("openrouter/".length) : normalized;
return normalized.startsWith(OPENROUTER_MODEL_PREFIX)
? normalized.slice(OPENROUTER_MODEL_PREFIX.length)
: normalized;
}
export function normalizeOpenRouterApiModelId(modelId: unknown): string | undefined {
if (typeof modelId !== "string") {
return undefined;
}
const normalized = normalizeLowercaseStringOrEmpty(modelId);
if (!normalized.startsWith(OPENROUTER_MODEL_PREFIX)) {
return normalized;
}
const unprefixed = normalized.slice(OPENROUTER_MODEL_PREFIX.length);
// `openrouter/` is both a provider qualifier and an upstream namespace.
// Strip it only when the remainder is still a namespaced API model id.
return unprefixed.includes("/") ? unprefixed : normalized;
}
export function isOpenRouterMistralModelId(modelId: unknown): boolean {

View File

@@ -7,12 +7,17 @@ import {
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
import { normalizeOpenRouterApiModelId } from "./models.js";
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
const OPENROUTER_MISTRAL_PROVIDER_PREFIX = "mistralai/";
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? "";
const LIVE_MODEL_ID =
process.env.OPENCLAW_LIVE_OPENROUTER_PLUGIN_MODEL?.trim() || "openai/gpt-5.4-nano";
const LIVE_MODEL_REF =
process.env.OPENCLAW_LIVE_OPENROUTER_PLUGIN_MODEL?.trim() ||
"openrouter/anthropic/claude-sonnet-4.6";
const LIVE_MODEL_ID = LIVE_MODEL_REF.startsWith("openrouter/")
? LIVE_MODEL_REF
: `openrouter/${LIVE_MODEL_REF}`;
const LIVE_CACHE_MODEL_ID =
process.env.OPENCLAW_LIVE_OPENROUTER_CACHE_MODEL?.trim() || "deepseek/deepseek-v3.2";
const liveEnabled = OPENROUTER_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1";
@@ -57,6 +62,40 @@ async function completeOpenRouterChat(params: {
});
}
async function expectWeatherToolCall(client: OpenAI, model: string): Promise<void> {
const response = await client.chat.completions.create({
model,
messages: [{ role: "user", content: "Call get_weather for Paris." }],
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Get the weather for a city.",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
additionalProperties: false,
},
},
},
],
tool_choice: {
type: "function",
function: { name: "get_weather" },
},
max_tokens: 64,
});
const toolCall = response.choices[0]?.message?.tool_calls?.find(
(call) => call.type === "function",
);
expect(toolCall?.type).toBe("function");
expect(toolCall?.function.name).toBe("get_weather");
expect(JSON.parse(toolCall?.function.arguments ?? "{}")).toMatchObject({ city: "Paris" });
}
async function fetchOpenRouterModelIds(): Promise<string[]> {
const response = await fetch(OPENROUTER_MODELS_URL, {
headers: { "accept-encoding": "identity" },
@@ -69,7 +108,7 @@ async function fetchOpenRouterModelIds(): Promise<string[]> {
}
describeLive("openrouter plugin live", () => {
it("registers an OpenRouter provider that can complete a live request", async () => {
it("normalizes a prefixed OpenRouter model and completes a live tool call", async () => {
const { providers } = await registerOpenRouterPlugin();
const provider = requireRegisteredProvider(providers, "openrouter");
@@ -87,17 +126,35 @@ describeLive("openrouter plugin live", () => {
expect(resolved.api).toBe("openai-completions");
expect(resolved.baseUrl).toBe("https://openrouter.ai/api/v1");
const normalized =
provider.normalizeResolvedModel?.({
provider: "openrouter",
modelId: resolved.id,
model: resolved,
}) ?? resolved;
expect(normalized.id).toBe(normalizeOpenRouterApiModelId(LIVE_MODEL_ID));
const client = new OpenAI({
apiKey: OPENROUTER_API_KEY,
baseURL: resolved.baseUrl,
baseURL: normalized.baseUrl,
});
const response = await client.chat.completions.create({
model: resolved.id,
messages: [{ role: "user", content: "Reply with exactly OK." }],
max_tokens: 16,
const autoResolved = provider.resolveDynamicModel?.({
provider: "openrouter",
modelId: "openrouter/auto",
modelRegistry: new ModelRegistryCtor(AuthStorage.inMemory()),
});
expect(response.choices[0]?.message?.content?.trim()).toMatch(/^OK[.!]?$/);
if (!autoResolved) {
throw new Error("openrouter provider did not resolve openrouter/auto");
}
const autoModel =
provider.normalizeResolvedModel?.({
provider: "openrouter",
modelId: autoResolved.id,
model: autoResolved,
}) ?? autoResolved;
expect(autoModel.id).toBe("openrouter/auto");
await expectWeatherToolCall(client, autoModel.id);
await expectWeatherToolCall(client, normalized.id);
}, 30_000);
});

View File

@@ -315,7 +315,7 @@ describe("qa-lab server", () => {
controlUiUrl: string | null;
controlUiEmbeddedUrl: string | null;
kickoffTask: string;
scenarios: Array<{ id: string; title: string }>;
scenarios: Array<{ id: string; title: string; execution?: { kind?: string } }>;
defaults: { conversationId: string; senderId: string };
runner: { status: string; selection: { providerMode: string; scenarioIds: string[] } };
};
@@ -328,7 +328,12 @@ describe("qa-lab server", () => {
expect(bootstrap.scenarios.map((scenario) => scenario.id)).toContain("dm-chat-baseline");
expect(bootstrap.runner.status).toBe("idle");
expect(bootstrap.runner.selection.providerMode).toBe("live-frontier");
expect(bootstrap.runner.selection.scenarioIds).toHaveLength(bootstrap.scenarios.length);
const flowScenarioIds = bootstrap.scenarios
.filter(
(scenario) => scenario.execution?.kind === undefined || scenario.execution.kind === "flow",
)
.map((scenario) => scenario.id);
expect(bootstrap.runner.selection.scenarioIds).toEqual(flowScenarioIds);
const startupStatus = (await (
await fetchWithRetry(`${lab.baseUrl}/api/capture/startup-status`)

View File

@@ -2,6 +2,19 @@
import { describe, expect, it } from "vitest";
import { slackDoctor } from "./doctor.js";
async function collectSlackWarnings(
slack: Record<string, unknown>,
defaults?: Record<string, unknown>,
) {
return (
(await Promise.resolve(
slackDoctor.collectMutableAllowlistWarnings?.({
cfg: { channels: { ...(defaults ? { defaults } : {}), slack } } as never,
}),
)) ?? []
);
}
function getSlackCompatibilityNormalizer(): NonNullable<
typeof slackDoctor.normalizeCompatibilityConfig
> {
@@ -50,6 +63,236 @@ describe("slack doctor", () => {
).toBe(true);
});
it("warns for name-keyed allowlist channels but accepts routed ID forms (#81665)", async () => {
const warnings = await collectSlackWarnings({
channels: {
"example-channel": {},
community: {},
C0AL2GDUA7J: {},
c0al2gdua7k: {},
"channel:C0AL2GDUA7L": {},
"channel:c0al2gdua7m": {},
D0AL2GDUA7Q: {},
"channel:d0al2gdua7r": {},
"channel:dabcdefgh": {},
"channel:customers": {},
"CHANNEL:C0AL2GDUA7N": {},
"channel:C0al2gdua7p": {},
"*": {},
},
});
const nameKeyWarnings = warnings.filter((warning) =>
warning.includes("Re-key it with the channel's"),
);
expect(nameKeyWarnings).toHaveLength(5);
expect(nameKeyWarnings[0]).toContain('channels.slack.channels."example-channel"');
expect(nameKeyWarnings[0]).toContain('channels.slack.channels."*" applies instead');
expect(nameKeyWarnings[1]).toContain('channels.slack.channels."community" is ambiguous');
expect(nameKeyWarnings[2]).toContain(
'channels.slack.channels."channel:customers" is ambiguous',
);
expect(nameKeyWarnings[3]).toContain('channels.slack.channels."CHANNEL:C0AL2GDUA7N"');
expect(nameKeyWarnings[4]).toContain('channels.slack.channels."channel:C0al2gdua7p"');
const dmWarnings = warnings.filter((warning) =>
warning.includes("is a Slack DM conversation ID"),
);
expect(dmWarnings).toHaveLength(3);
expect(dmWarnings[0]).toContain('channels.slack.channels."D0AL2GDUA7Q"');
expect(dmWarnings[1]).toContain('channels.slack.channels."channel:d0al2gdua7r"');
expect(dmWarnings[2]).toContain('channels.slack.channels."channel:dabcdefgh"');
expect(dmWarnings[0]).toContain("channels.slack.dmPolicy");
});
it("uses account policy and name-matching overrides for name-keyed channels (#81665)", async () => {
const overlongName = "a".repeat(81);
const warnings = await collectSlackWarnings({
groupPolicy: "open",
channels: { "root-room": {} },
accounts: {
inheritedOpen: {
channels: { general: {} },
},
inheritedAllowlist: {
groupPolicy: "allowlist",
},
explicitAllowlist: {
groupPolicy: "allowlist",
channels: { engineering: {} },
},
nameMatching: {
groupPolicy: "allowlist",
dangerouslyAllowNameMatching: true,
channels: {
support: {},
"#help": {},
"crème-brûlée": {},
d0customers: {},
dabcdefgh: {},
"channel:customers": {},
"<#C0AL2GDUA7J>": {},
"slack:C0AL2GDUA7K": {},
"@help": {},
"##help": {},
"help+": {},
Support: {},
"-": {},
___: {},
"#--": {},
[overlongName]: {},
},
},
},
});
const nameKeyWarnings = warnings.filter((warning) =>
warning.includes("Re-key it with the channel's"),
);
expect(nameKeyWarnings).toHaveLength(13);
const rootWarning = nameKeyWarnings.find((warning) =>
warning.includes('channels.slack.channels."root-room"'),
);
expect(rootWarning).toContain("messages from the channel are dropped");
expect(
nameKeyWarnings.some((warning) =>
warning.includes('channels.slack.accounts.explicitAllowlist.channels."engineering"'),
),
).toBe(true);
expect(
nameKeyWarnings.some((warning) =>
warning.includes(
'channels.slack.accounts.nameMatching.channels."channel:customers" is ambiguous',
),
),
).toBe(true);
expect(
nameKeyWarnings.some((warning) =>
warning.includes('channels.slack.accounts.nameMatching.channels."<#C0AL2GDUA7J>"'),
),
).toBe(true);
expect(
nameKeyWarnings.some((warning) =>
warning.includes('channels.slack.accounts.nameMatching.channels."slack:C0AL2GDUA7K"'),
),
).toBe(true);
for (const invalidName of [
"@help",
"##help",
"help+",
"Support",
"-",
"___",
"#--",
overlongName,
]) {
expect(
nameKeyWarnings.some((warning) =>
warning.includes(`channels.slack.accounts.nameMatching.channels."${invalidName}"`),
),
).toBe(true);
}
const sharedOpenWarnings = await collectSlackWarnings(
{ channels: { "shared-room": {} } },
{ groupPolicy: "open" },
);
expect(
sharedOpenWarnings.some((warning) => warning.includes("not a routable Slack channel ID")),
).toBe(true);
});
it("warns when an open-policy override is keyed by channel name (#81665)", async () => {
const warnings = await collectSlackWarnings({
groupPolicy: "open",
channels: {
"private-room": { enabled: false },
},
});
expect(warnings).toEqual([expect.stringContaining('channels.slack.channels."private-room"')]);
expect(warnings[0]).toContain("the channel remains allowed");
});
it("warns for DM IDs regardless of room policy and uses account-scoped remediation", async () => {
const openWarnings = await collectSlackWarnings({
groupPolicy: "open",
channels: {
D0AL2GDUA7S: {},
},
});
expect(openWarnings).toEqual([
expect.stringContaining('channels.slack.channels."D0AL2GDUA7S"'),
]);
const disabledAccountWarnings = await collectSlackWarnings({
accounts: {
work: {
groupPolicy: "disabled",
channels: {
"channel:d0al2gdua7t": {},
},
},
},
});
expect(disabledAccountWarnings).toEqual([
expect.stringContaining('channels.slack.accounts.work.channels."channel:d0al2gdua7t"'),
]);
expect(disabledAccountWarnings[0]).toContain("channels.slack.accounts.work.dmPolicy");
expect(disabledAccountWarnings[0]).toContain("channels.slack.accounts.work.allowFrom");
const inheritedChannelWarnings = await collectSlackWarnings({
channels: {
D0AL2GDUA7U: {},
},
accounts: {
work: {
groupPolicy: "disabled",
dmPolicy: "allowlist",
allowFrom: ["U0AL2GDUA7U"],
},
},
});
expect(inheritedChannelWarnings).toEqual([
expect.stringContaining('channels.slack.channels."D0AL2GDUA7U"'),
]);
expect(inheritedChannelWarnings[0]).toContain("channels.slack.accounts.work.dmPolicy");
});
it("treats bare lowercase D forms as ambiguous without name matching", async () => {
const warnings = await collectSlackWarnings({
channels: {
d0customers: {},
dabcdefgh: {},
},
});
expect(warnings).toHaveLength(2);
expect(warnings[0]).toContain(
'channels.slack.channels."d0customers" is ambiguous: it may be a lowercase Slack DM conversation ID or a channel name',
);
expect(warnings[1]).toContain(
'channels.slack.channels."dabcdefgh" is ambiguous: it may be a lowercase Slack DM conversation ID or a channel name',
);
expect(warnings[0]).toContain("stable C/G ID");
});
it("does not audit provider defaults as a standalone named account (#81665)", async () => {
const warnings = await collectSlackWarnings({
channels: {
"provider-room": { enabled: false },
},
accounts: {
work: {
channels: {
C0AL2GDUA7J: {},
},
},
},
});
expect(warnings.some((warning) => warning.includes("provider-room"))).toBe(false);
});
it("normalizes legacy slack streaming aliases into the nested streaming shape", () => {
const normalize = getSlackCompatibilityNormalizer();

View File

@@ -1,6 +1,8 @@
// Slack plugin module implements doctor behavior.
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import type { GroupPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { listSlackAccountIds, mergeSlackAccountConfig } from "./accounts.js";
import {
legacyConfigRules as SLACK_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig as normalizeSlackCompatibilityConfig,
@@ -48,6 +50,134 @@ const collectSlackMutableAllowlistWarnings =
},
});
const SLACK_CANONICAL_CHANNEL_ID_RE = /^[CG][A-Z0-9]{8,}$/;
const SLACK_LOWERCASE_CHANNEL_ID_RE = /^[cg][0-9][a-z0-9]{7,}$/;
const SLACK_PREFIXED_CANONICAL_CHANNEL_ID_RE = /^channel:[CG][A-Z0-9]{8,}$/;
const SLACK_PREFIXED_LOWERCASE_CHANNEL_ID_RE = /^channel:[cg][0-9][a-z0-9]{7,}$/;
const SLACK_CANONICAL_DM_ID_RE = /^(?:channel:)?D[A-Z0-9]{8,}$/;
const SLACK_PREFIXED_LOWERCASE_DM_ID_RE = /^channel:d[a-z0-9]{8,}$/;
const SLACK_AMBIGUOUS_LOWERCASE_DM_ID_RE = /^d[a-z0-9]{8,}$/;
// Letter-leading lowercase forms may be valid IDs or human names. Warn conditionally instead of
// claiming they are unroutable.
const SLACK_AMBIGUOUS_LOWERCASE_CHANNEL_ID_RE = /^(?:channel:)?[cgd][a-z][a-z0-9]{7,}$/;
// Slack supports international channel names, and runtime name matching preserves exact names.
// Keep Unicode letters/marks/numbers while enforcing lowercase, length, and punctuation rules.
const SLACK_CHANNEL_NAME_RE = /^[\p{L}\p{M}\p{N}_-]{1,80}$/u;
const SLACK_CHANNEL_NAME_ALPHANUMERIC_RE = /[\p{L}\p{N}]/u;
function looksLikeSlackChannelId(channelKey: string): boolean {
return (
SLACK_CANONICAL_CHANNEL_ID_RE.test(channelKey) ||
SLACK_LOWERCASE_CHANNEL_ID_RE.test(channelKey) ||
SLACK_PREFIXED_CANONICAL_CHANNEL_ID_RE.test(channelKey) ||
SLACK_PREFIXED_LOWERCASE_CHANNEL_ID_RE.test(channelKey)
);
}
function looksLikeSlackDmId(channelKey: string): boolean {
return (
SLACK_CANONICAL_DM_ID_RE.test(channelKey) || SLACK_PREFIXED_LOWERCASE_DM_ID_RE.test(channelKey)
);
}
function looksLikeSlackChannelNameKey(channelKey: string): boolean {
const name = channelKey.startsWith("#") ? channelKey.slice(1) : channelKey;
return (
name === name.toLowerCase() &&
SLACK_CHANNEL_NAME_RE.test(name) &&
SLACK_CHANNEL_NAME_ALPHANUMERIC_RE.test(name)
);
}
// Startup resolution updates ctx.channelsConfig, but inbound authorization captures the authored
// channels map and key list when createSlackMonitorContext runs. Diagnose those authored keys.
function collectSlackNameKeyedChannelWarnings({ cfg }: { cfg: OpenClawConfig }): string[] {
const warnings = new Set<string>();
const slackCfg = asObjectRecord(asObjectRecord(cfg.channels)?.slack);
const providerChannels = asObjectRecord(slackCfg?.channels);
const accounts = asObjectRecord(slackCfg?.accounts);
for (const accountId of listSlackAccountIds(cfg)) {
const account = asObjectRecord(mergeSlackAccountConfig(cfg, accountId));
if (!account || slackCfg?.enabled === false || account.enabled === false) {
continue;
}
const scopedGroupPolicy =
typeof account.groupPolicy === "string" ? (account.groupPolicy as GroupPolicy) : undefined;
// Slack's schema materializes this provider default before runtime account merging.
const effectiveGroupPolicy = scopedGroupPolicy ?? "allowlist";
const rawAccount = asObjectRecord(accounts?.[accountId]);
const accountPrefix = rawAccount ? `channels.slack.accounts.${accountId}` : "channels.slack";
const accountChannels = asObjectRecord(rawAccount?.channels);
const channels = accountChannels ?? providerChannels;
if (!channels) {
continue;
}
const channelsPrefix = accountChannels
? `channels.slack.accounts.${accountId}`
: "channels.slack";
const fallbackDescription = Object.hasOwn(channels, "*")
? `${channelsPrefix}.channels."*" applies instead and this entry's overrides are ignored`
: effectiveGroupPolicy === "open"
? 'this entry\'s overrides are ignored and the channel remains allowed by groupPolicy: "open"'
: "messages from the channel are dropped";
for (const channelKey of Object.keys(channels)) {
if (channelKey === "*") {
continue;
}
if (looksLikeSlackDmId(channelKey)) {
warnings.add(
`${channelsPrefix}.channels."${channelKey}" is a Slack DM conversation ID, but ${channelsPrefix}.channels only configures channel and group rooms. ` +
`Configure DM access with ${accountPrefix}.dmPolicy and ${accountPrefix}.allowFrom instead.`,
);
continue;
}
if (SLACK_AMBIGUOUS_LOWERCASE_DM_ID_RE.test(channelKey)) {
if (
account.dangerouslyAllowNameMatching === true &&
looksLikeSlackChannelNameKey(channelKey)
) {
continue;
}
warnings.add(
`${channelsPrefix}.channels."${channelKey}" is ambiguous: it may be a lowercase Slack DM conversation ID or a channel name. ` +
`Configure DMs with ${accountPrefix}.dmPolicy and ${accountPrefix}.allowFrom; otherwise re-key the room with its stable C/G ID.`,
);
continue;
}
if (effectiveGroupPolicy === "disabled") {
continue;
}
const channelConfig = asObjectRecord(channels[channelKey]);
if (effectiveGroupPolicy === "open" && Object.keys(channelConfig ?? {}).length === 0) {
continue;
}
if (looksLikeSlackChannelId(channelKey)) {
continue;
}
if (
account.dangerouslyAllowNameMatching === true &&
looksLikeSlackChannelNameKey(channelKey)
) {
continue;
}
if (SLACK_AMBIGUOUS_LOWERCASE_CHANNEL_ID_RE.test(channelKey)) {
warnings.add(
`${channelsPrefix}.channels."${channelKey}" is ambiguous: it may be a lowercase Slack channel ID or a channel name. ` +
`If it is a channel name, inbound routing will not match it and ${fallbackDescription}. ` +
`Re-key it with the channel's stable ID (e.g. C0123ABCD, from the channel's About details or conversations.info).`,
);
continue;
}
warnings.add(
`${channelsPrefix}.channels."${channelKey}" is keyed by a channel name or non-canonical ID form, not a routable Slack channel ID; ` +
`under groupPolicy: "${effectiveGroupPolicy}" inbound routing does not match this entry, so ${fallbackDescription}. ` +
`Re-key it with the channel's ID (e.g. C0123ABCD, from the channel's About details or conversations.info).`,
);
}
}
return [...warnings];
}
export const slackDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "topOnly",
groupModel: "route",
@@ -55,5 +185,8 @@ export const slackDoctor: ChannelDoctorAdapter = {
warnOnEmptyGroupSenderAllowlist: false,
legacyConfigRules: SLACK_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: normalizeSlackCompatibilityConfig,
collectMutableAllowlistWarnings: collectSlackMutableAllowlistWarnings,
collectMutableAllowlistWarnings: ({ cfg }) => [
...collectSlackMutableAllowlistWarnings({ cfg }),
...collectSlackNameKeyedChannelWarnings({ cfg }),
],
};

View File

@@ -2438,7 +2438,7 @@ export function buildFullSuiteVitestRunPlans(args, cwd = process.cwd()) {
const expandToProjectConfigs =
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS === "1" ||
(Number.isFinite(parallelShardCount) && parallelShardCount > 1) ||
shouldUseLocalFullSuiteParallelByDefault(process.env);
shouldExpandLocalFullSuiteShardsByDefault(process.env);
return fullSuiteVitestShards.flatMap((shard) => {
if (
process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD === "1" &&
@@ -2484,6 +2484,10 @@ export function shouldUseLocalFullSuiteParallelByDefault(env = process.env) {
);
}
export function shouldExpandLocalFullSuiteShardsByDefault(env = process.env) {
return env.CI !== "true" && env.GITHUB_ACTIONS !== "true";
}
function parsePositiveInt(value, label) {
const text = value?.trim();
if (!text) {

View File

@@ -3016,6 +3016,52 @@ describe("resolveModel", () => {
});
});
it("uses provider-normalized model ids for OpenRouter transport", () => {
const modelId = "openrouter/anthropic/claude-sonnet-4.6";
mockDiscoveredModel(discoverModels, {
provider: "openrouter",
modelId,
templateModel: {
...makeModel(modelId),
provider: "openrouter",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
},
});
const baseRuntimeHooks = createRuntimeHooks();
const normalizeProviderResolvedModelWithPlugin = vi.fn(
(params: { context: { model: { id: string } } }) => ({
...params.context.model,
id: params.context.model.id.slice("openrouter/".length),
}),
);
const result = resolveModel("openrouter", modelId, "/tmp/agent", undefined, {
authStorage: { mocked: true } as never,
modelRegistry: discoverModels({ mocked: true } as never, "/tmp/agent"),
runtimeHooks: {
...baseRuntimeHooks,
normalizeProviderResolvedModelWithPlugin,
},
});
expect(normalizeProviderResolvedModelWithPlugin).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openrouter",
context: expect.objectContaining({
modelId,
model: expect.objectContaining({ id: modelId }),
}),
}),
);
expectRecordFields(result.model, {
provider: "openrouter",
id: "anthropic/claude-sonnet-4.6",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
});
});
it("matches prefixed Hugging Face ids against discovered registry models", () => {
mockDiscoveredModel(discoverModels, {
provider: "huggingface",

View File

@@ -7,6 +7,7 @@ import {
import { resetTaskRegistryForTests, type TaskRecord } from "../../../tasks/runtime-internal.js";
import {
requiresCompletionRequiredAsyncTaskWait,
shouldWaitForCompletionRequiredAsyncTasks,
waitForCompletionRequiredAsyncTasks,
type AsyncStartedToolMeta,
} from "./attempt.async-tasks.js";
@@ -97,6 +98,46 @@ describe("waitForCompletionRequiredAsyncTasks", () => {
).toBe(true);
});
it("skips media task waiting after sessions_yield pauses the attempt", () => {
resetTaskRegistryForTests();
const sessionKey = "agent:main:cron:daily-media:run:run-123";
createRunningTaskRun({
runtime: "cli",
taskKind: "image_generation",
sourceId: "image_generate:openai",
requesterSessionKey: sessionKey,
ownerKey: sessionKey,
scopeKind: "session",
runId: "tool:image_generate:run-123",
task: "daily image",
deliveryStatus: "not_applicable",
notifyPolicy: "silent",
startedAt: 1,
lastEventAt: 1,
});
expect(
shouldWaitForCompletionRequiredAsyncTasks({
sessionKey,
toolMetas: [
{
toolName: "image_generate",
asyncStarted: true,
asyncTaskRunId: "tool:image_generate:run-123",
},
],
yieldDetected: true,
}),
).toBe(false);
expect(
shouldWaitForCompletionRequiredAsyncTasks({
sessionKey,
toolMetas: [],
yieldDetected: false,
}),
).toBe(true);
});
it("waits for active cron media tasks from the task registry", async () => {
// Cron media tools may start tasks before metadata is flushed, so the
// registry is also consulted by session key.

View File

@@ -160,6 +160,23 @@ export function requiresCompletionRequiredAsyncTaskWait(params: {
);
}
/** Returns whether the current attempt should synchronously wait for media tasks. */
export function shouldWaitForCompletionRequiredAsyncTasks(params: {
sessionKey: string | undefined;
toolMetas: readonly AsyncStartedToolMeta[];
yieldDetected?: boolean;
}): boolean {
if (params.yieldDetected === true) {
// sessions_yield pauses the turn so the completion event can wake it later;
// waiting here would reuse the internal abort signal and turn the pause into AbortError.
return false;
}
return requiresCompletionRequiredAsyncTaskWait({
sessionKey: params.sessionKey,
toolMetas: params.toolMetas,
});
}
/**
* Polls completion-required async tasks until they reach terminal state, time
* out at the run deadline, or abort. Newly discovered task run ids are folded

View File

@@ -316,6 +316,7 @@ import {
} from "./attempt-trajectory-status.js";
import {
requiresCompletionRequiredAsyncTaskWait,
shouldWaitForCompletionRequiredAsyncTasks,
waitForCompletionRequiredAsyncTasks,
type AsyncStartedToolMeta,
type CompletionRequiredAsyncTaskWaitResult,
@@ -4571,9 +4572,10 @@ export async function runEmbeddedAttempt(
await sessionLockController.releaseForPrompt();
if (
requiresCompletionRequiredAsyncTaskWait({
shouldWaitForCompletionRequiredAsyncTasks({
sessionKey: params.sessionKey,
toolMetas,
yieldDetected: yieldAborted,
})
) {
const getAsyncStartedToolMetas = () =>

View File

@@ -2,9 +2,11 @@
* Regression coverage for non-secret model-auth marker helpers.
* Verifies core, plugin, env-var, OAuth, AWS, and secret-ref marker handling.
*/
import { fileURLToPath } from "node:url";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { withEnv, withEnvAsync } from "../test-utils/env.js";
const BUNDLED_PLUGINS_DIR = fileURLToPath(new URL("../../extensions/", import.meta.url));
const PLUGIN_MANIFEST_ENV_KEYS = [
"OPENCLAW_BUNDLED_PLUGINS_DIR",
"OPENCLAW_DISABLE_BUNDLED_PLUGINS",
@@ -14,9 +16,12 @@ const PLUGIN_MANIFEST_ENV_KEYS = [
"OPENCLAW_TEST_MINIMAL_GATEWAY",
] as const;
function cleanPluginManifestEnv(): Record<(typeof PLUGIN_MANIFEST_ENV_KEYS)[number], undefined> {
function cleanPluginManifestEnv(): Record<
(typeof PLUGIN_MANIFEST_ENV_KEYS)[number],
string | undefined
> {
return {
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
OPENCLAW_BUNDLED_PLUGINS_DIR: BUNDLED_PLUGINS_DIR,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
OPENCLAW_SKIP_PROVIDERS: undefined,
OPENCLAW_SKIP_CHANNELS: undefined,
@@ -35,6 +40,7 @@ let listKnownNonSecretApiKeyMarkers: typeof import("./model-auth-markers.js").li
let resolveOAuthApiKeyMarker: typeof import("./model-auth-markers.js").resolveOAuthApiKeyMarker;
async function loadMarkerModules() {
vi.doUnmock("../plugins/manifest-metadata-scan.js");
vi.doUnmock("../plugins/manifest-registry.js");
vi.doUnmock("../secrets/provider-env-vars.js");
vi.resetModules();

View File

@@ -36,6 +36,7 @@ const AWS_SDK_ENV_MARKERS = new Set([
const CORE_NON_SECRET_API_KEY_MARKERS = [
CUSTOM_LOCAL_AUTH_MARKER,
CODEX_APP_SERVER_AUTH_MARKER,
GCP_VERTEX_CREDENTIALS_MARKER,
OLLAMA_LOCAL_AUTH_MARKER,
NON_ENV_SECRETREF_MARKER,
] as const;

View File

@@ -29,6 +29,19 @@ vi.mock("../plugins/plugin-registry.js", () => ({
}),
}));
vi.mock("../plugins/manifest-metadata-scan.js", () => ({
listOpenClawPluginManifestMetadata: () => [
{
pluginDir: "/bundled/anthropic-vertex",
origin: "bundled",
manifest: {
id: "anthropic-vertex",
nonSecretAuthMarkers: ["gcp-vertex-credentials"],
},
},
],
}));
vi.mock("../plugins/providers.js", () => ({
resolveOwningPluginIdsForProvider: () => [],
resolveOwningPluginIdsForProviderRef: () => [],

View File

@@ -68,19 +68,35 @@ describe("loadModelCatalogForBrowse", () => {
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false });
});
it("uses the full catalog when configured visibility has provider wildcards", async () => {
it("uses the read-only catalog when configured visibility has provider wildcards", async () => {
const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) =>
readOnly ? readOnlyCatalog : fullCatalog,
);
await expect(
loadModelCatalogForBrowse({ cfg: config({ providerWildcard: true }), loadCatalog }),
).resolves.toBe(readOnlyCatalog);
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: true });
});
it("uses the full catalog for configured views with provider wildcards", async () => {
const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) =>
readOnly ? readOnlyCatalog : fullCatalog,
);
await expect(
loadModelCatalogForBrowse({
cfg: config({ providerWildcard: true }),
view: "configured",
loadCatalog,
}),
).resolves.toBe(fullCatalog);
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false });
});
it("returns an empty catalog when read-only catalog loading times out", async () => {
it("returns an empty catalog when read-only catalog loading times out with provider wildcards", async () => {
const onTimeout = vi.fn();
const timeoutHandle = { unref: vi.fn() } as unknown as NodeJS.Timeout;
const clearTimeout = vi.fn();
@@ -94,7 +110,7 @@ describe("loadModelCatalogForBrowse", () => {
const loadCatalog = vi.fn(() => new Promise<ModelCatalogEntry[]>(() => {}));
const resultPromise = loadModelCatalogForBrowse({
cfg: config(),
cfg: config({ providerWildcard: true }),
loadCatalog,
timeoutMs: 5,
onTimeout,

View File

@@ -36,13 +36,6 @@ export function restoreModelCatalogBrowseTestDeps(): void {
modelCatalogBrowseDeps.clearTimeout = globalThis.clearTimeout;
}
function resolveModelCatalogBrowseTimeoutMs(value: number | undefined): number {
return (
clampTimerTimeoutMs(value, 1) ??
resolveTimerTimeoutMs(DEFAULT_MODEL_CATALOG_BROWSE_TIMEOUT_MS, 1)
);
}
/** True when a browse view cannot be answered from read-only cached catalog entries. */
export function modelCatalogBrowseRequiresFullDiscovery(params: {
cfg: OpenClawConfig;
@@ -51,7 +44,15 @@ export function modelCatalogBrowseRequiresFullDiscovery(params: {
const view = params.view ?? "default";
return (
view === "all" ||
parseConfiguredModelVisibilityEntries({ cfg: params.cfg }).providerWildcards.size > 0
(view === "configured" &&
parseConfiguredModelVisibilityEntries({ cfg: params.cfg }).providerWildcards.size > 0)
);
}
function resolveModelCatalogBrowseTimeoutMs(value: number | undefined): number {
return (
clampTimerTimeoutMs(value, 1) ??
resolveTimerTimeoutMs(DEFAULT_MODEL_CATALOG_BROWSE_TIMEOUT_MS, 1)
);
}
@@ -65,7 +66,6 @@ export async function loadModelCatalogForBrowse(params: {
}): Promise<ModelCatalogEntry[]> {
const view = params.view ?? "default";
if (modelCatalogBrowseRequiresFullDiscovery({ cfg: params.cfg, view })) {
// Wildcards depend on provider discovery; read-only cached entries can hide matching models.
return await params.loadCatalog({ readOnly: false });
}

View File

@@ -10,6 +10,11 @@ import { isCliRuntimeProvider } from "./model-runtime-aliases.js";
// model picker choices. Hide them while keeping real provider/model refs visible.
const RETIRED_MODEL_PICKER_PROVIDERS = new Set(["codex", "codex-cli"]);
/** True for retired provider ids that should stay out of model selection surfaces. */
export function isRetiredModelPickerProvider(provider: string): boolean {
return RETIRED_MODEL_PICKER_PROVIDERS.has(normalizeProviderId(provider));
}
/** Creates a provider visibility predicate for model picker rendering. */
export function createModelPickerVisibleProviderPredicate(
params: { config?: OpenClawConfig; env?: NodeJS.ProcessEnv; includeSetupRegistry?: boolean } = {},
@@ -23,7 +28,7 @@ export function createModelPickerVisibleProviderPredicate(
);
return (provider: string): boolean => {
const normalized = normalizeProviderId(provider);
return !RETIRED_MODEL_PICKER_PROVIDERS.has(normalized) && !cliRuntimeProviders.has(normalized);
return !isRetiredModelPickerProvider(normalized) && !cliRuntimeProviders.has(normalized);
};
}
@@ -31,7 +36,7 @@ export function createModelPickerVisibleProviderPredicate(
export function isModelPickerVisibleProvider(provider: string): boolean {
const normalized = normalizeProviderId(provider);
return (
!RETIRED_MODEL_PICKER_PROVIDERS.has(normalized) &&
!isRetiredModelPickerProvider(normalized) &&
!isCliRuntimeProvider(normalized, { includeSetupRegistry: true })
);
}

View File

@@ -234,6 +234,19 @@ describe("prepared provider auth state", () => {
).resolves.toBe(false);
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2);
// Bounded browse callers may explicitly consume the prepared broad answer
// while keeping slow fallback discovery disabled.
await expect(
hasAuthForModelProvider({
provider: "openai",
cfg,
discoverExternalCliAuth: false,
allowPluginSyntheticAuth: false,
allowPreparedRuntimeAuth: true,
}),
).resolves.toBe(true);
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2);
// Broad-scope caller (default flags) still hits the prepared map.
await expect(hasAuthForModelProvider({ provider: "openai", cfg })).resolves.toBe(true);
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2);

View File

@@ -127,6 +127,7 @@ export async function hasAuthForModelProvider(params: {
store?: AuthProfileStore;
allowPluginSyntheticAuth?: boolean;
discoverExternalCliAuth?: boolean;
allowPreparedRuntimeAuth?: boolean;
runtimeAuthLookup?: RuntimeProviderAuthLookup;
resolveRuntimeAuthLookup?: () => RuntimeProviderAuthLookup;
}): Promise<boolean> {
@@ -162,8 +163,8 @@ export async function hasAuthForModelProvider(params: {
configFingerprint === preparedState.configFingerprint &&
workspaceDir === expectedWorkspaceDir &&
(params.agentDir === undefined || params.agentDir === expectedAgentDir) &&
params.discoverExternalCliAuth !== false &&
params.allowPluginSyntheticAuth !== false &&
(params.allowPreparedRuntimeAuth === true ||
(params.discoverExternalCliAuth !== false && params.allowPluginSyntheticAuth !== false)) &&
params.env === undefined &&
params.store === undefined &&
params.modelApi === undefined;
@@ -227,6 +228,7 @@ export function createProviderAuthChecker(params: {
env?: NodeJS.ProcessEnv;
allowPluginSyntheticAuth?: boolean;
discoverExternalCliAuth?: boolean;
allowPreparedRuntimeAuth?: boolean;
}): (provider: string, modelApi?: string) => Promise<boolean> {
const authCache = new Map<string, boolean>();
let runtimeAuthLookup: RuntimeProviderAuthLookup | undefined;
@@ -247,6 +249,7 @@ export function createProviderAuthChecker(params: {
env: params.env,
allowPluginSyntheticAuth: params.allowPluginSyntheticAuth,
discoverExternalCliAuth: params.discoverExternalCliAuth,
allowPreparedRuntimeAuth: params.allowPreparedRuntimeAuth,
resolveRuntimeAuthLookup: () =>
(runtimeAuthLookup ??= createRuntimeProviderAuthLookup({
cfg: params.cfg,

View File

@@ -2,7 +2,10 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { testing as cliBackendsTesting } from "./cli-backends.js";
import { createModelPickerVisibleProviderPredicate } from "./model-picker-visibility.js";
import {
createModelPickerVisibleProviderPredicate,
isRetiredModelPickerProvider,
} from "./model-picker-visibility.js";
import {
areRuntimeModelRefsEquivalent,
isCliRuntimeProvider,
@@ -169,6 +172,20 @@ describe("resolveCliRuntimeExecutionProvider", () => {
expect(isCliRuntimeProvider("acme-cli")).toBe(false);
expect(isVisibleProvider("acme-cli")).toBe(true);
});
it("recognizes retired picker providers without loading CLI backend metadata", () => {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupRegistry: () => {
throw new Error("retired provider checks should not load setup metadata");
},
resolveRuntimeCliBackends: () => {
throw new Error("retired provider checks should not load runtime metadata");
},
});
expect(isRetiredModelPickerProvider("CODEX-CLI")).toBe(true);
expect(isRetiredModelPickerProvider("anthropic")).toBe(false);
});
});
describe("areRuntimeModelRefsEquivalent", () => {

View File

@@ -2,6 +2,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createConfigRuntimeEnv } from "../config/env-vars.js";
@@ -17,6 +18,7 @@ import type { ProviderConfig } from "./models-config.providers.secrets.js";
import { encodePluginModelCatalogRelativePath } from "./plugin-model-catalog.js";
const TEST_ENV_VAR = "OPENCLAW_MODELS_CONFIG_TEST_ENV";
const BUNDLED_PLUGINS_DIR = fileURLToPath(new URL("../../extensions/", import.meta.url));
function createImplicitOpenRouterProvider(): ProviderConfig {
return {
@@ -533,33 +535,42 @@ describe("models-config", () => {
const credentialsPath = path.join(agentDir, "application_default_credentials.json");
await fs.writeFile(credentialsPath, JSON.stringify({ type: "authorized_user" }), "utf8");
try {
const plan = await planOpenClawModelsJsonWithDeps(
const plan = await withEnvAsync(
{
cfg: {
agents: {
defaults: {
models: {
"google-vertex/gemini-2.5-pro": {},
OPENCLAW_BUNDLED_PLUGINS_DIR: BUNDLED_PLUGINS_DIR,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
},
async () =>
await planOpenClawModelsJsonWithDeps(
{
cfg: {
agents: {
defaults: {
models: {
"google-vertex/gemini-2.5-pro": {},
},
model: { primary: "google-vertex/gemini-2.5-pro" },
},
},
model: { primary: "google-vertex/gemini-2.5-pro" },
models: { providers: {} },
},
agentDir,
env: {
OPENCLAW_BUNDLED_PLUGINS_DIR: BUNDLED_PLUGINS_DIR,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_PROJECT: "vertex-project",
GOOGLE_CLOUD_LOCATION: "global",
} as NodeJS.ProcessEnv,
existingRaw: "",
existingParsed: null,
},
models: { providers: {} },
},
agentDir,
env: {
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_PROJECT: "vertex-project",
GOOGLE_CLOUD_LOCATION: "global",
} as NodeJS.ProcessEnv,
existingRaw: "",
existingParsed: null,
},
{
resolveImplicitProviders: async () => ({
"google-vertex": createImplicitGoogleVertexProvider(),
}),
},
{
resolveImplicitProviders: async () => ({
"google-vertex": createImplicitGoogleVertexProvider(),
}),
},
),
);
expect(plan.action).toBe("write");

View File

@@ -2,15 +2,18 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginMetadataSnapshotOwnerMaps } from "../plugins/plugin-metadata-snapshot.js";
import type { ProviderPlugin } from "../plugins/types.js";
import { withEnvAsync } from "../test-utils/env.js";
const mocks = vi.hoisted(() => ({
resolveRuntimePluginDiscoveryProviders: vi.fn(),
runProviderCatalog: vi.fn(),
runProviderStaticCatalog: vi.fn(),
}));
const BUNDLED_PLUGINS_DIR = fileURLToPath(new URL("../../extensions/", import.meta.url));
vi.mock("../plugins/provider-discovery.js", () => ({
resolveRuntimePluginDiscoveryProviders: mocks.resolveRuntimePluginDiscoveryProviders,
@@ -225,17 +228,26 @@ describe("resolveImplicitProviders startup discovery scope", () => {
},
});
const providers = await resolveImplicitProviders({
agentDir: "/tmp/openclaw-agent",
config: {},
env: {
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_PROJECT: "vertex-project",
GOOGLE_CLOUD_LOCATION: "global",
} as NodeJS.ProcessEnv,
explicitProviders: {},
providerDiscoveryEntriesOnly: true,
});
const providers = await withEnvAsync(
{
OPENCLAW_BUNDLED_PLUGINS_DIR: BUNDLED_PLUGINS_DIR,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
},
async () =>
await resolveImplicitProviders({
agentDir: "/tmp/openclaw-agent",
config: {},
env: {
OPENCLAW_BUNDLED_PLUGINS_DIR: BUNDLED_PLUGINS_DIR,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_PROJECT: "vertex-project",
GOOGLE_CLOUD_LOCATION: "global",
} as NodeJS.ProcessEnv,
explicitProviders: {},
providerDiscoveryEntriesOnly: true,
}),
);
expect(providers?.["google-vertex"]?.apiKey).toBe("gcp-vertex-credentials");
});

View File

@@ -1513,11 +1513,10 @@ describe("sessions tools", () => {
expect(calls.find((call) => call.method === "send")).toBeUndefined();
});
it("sessions_send reroutes run-scoped active deliveries when transcript steering is rejected", async () => {
it("sessions_send reports active-run queue rejection without durable-session fallback", async () => {
const calls: Array<{ method?: string; params?: unknown }> = [];
const requesterKey = "agent:re-portal:main";
const runScopedCallerKey = "agent:leasing-ops:cron:monthly-utility:run:run-fast";
const durableCallerKey = "agent:leasing-ops:cron:monthly-utility";
const queueMessage = vi.fn(async (_text: string, _options?: unknown) => {
throw new Error("active session ended before queued steering message was committed");
});
@@ -1539,13 +1538,6 @@ describe("sessions tools", () => {
if (request.method === "agent") {
return { runId: "fallback-run", status: "accepted", acceptedAt: 2000 };
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string } | undefined;
return { runId: params?.runId ?? "fallback-run", status: "ok" };
}
if (request.method === "chat.history") {
return { messages: [] };
}
return {};
});
@@ -1570,9 +1562,11 @@ describe("sessions tools", () => {
timeoutSeconds: 0,
});
const details = sessionsSendDetails(result.details);
expect(details.status).toBe("accepted");
expect(details.status).toBe("error");
expect(details.sessionKey).toBe(runScopedCallerKey);
expect(details.delivery?.status).toBe("pending");
expect(details.error).toContain("queue_message_failed reason=runtime_rejected");
expect(details.error).toContain("caller-active-session");
expect(details.error).not.toContain("fallback_failed");
const queuedText = queueMessage.mock.calls[0]?.[0];
expect(queuedText).toContain("[Inter-session message]");
expect(queuedText).toContain("[TASK-COMPLETE] re-portal occupancy ready");
@@ -1583,47 +1577,233 @@ describe("sessions tools", () => {
waitForTranscriptCommit: true,
sourceReplyDeliveryMode: "message_tool_only",
});
expect(calls.some((call) => call.method === "agent")).toBe(false);
});
await vi.waitFor(() => {
const fallbackCall = calls.find(
(call) =>
call.method === "agent" &&
(call.params as { sessionKey?: string } | undefined)?.sessionKey === durableCallerKey,
);
expect(fallbackCall).toBeDefined();
it("sessions_send reports source reply delivery mode mismatch without durable-session fallback", async () => {
const calls: Array<{ method?: string; params?: unknown }> = [];
const runScopedCallerKey = "agent:leasing-ops:cron:monthly-utility:run:run-fast";
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"caller-active-session",
{
queueMessage,
isStreaming: () => true,
isCompacting: () => false,
supportsTranscriptCommitWait: true,
sourceReplyDeliveryMode: "automatic",
abort: () => {},
},
runScopedCallerKey,
);
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
return { runId: "fallback-run", status: "accepted", acceptedAt: 2000 };
}
return {};
});
const tool = createOpenClawTools({
agentSessionKey: "agent:re-portal:main",
agentChannel: "telegram",
config: {
...TEST_CONFIG,
session: {
...TEST_CONFIG.session,
agentToAgent: { maxPingPongTurns: 0 },
},
},
}).find((candidate) => candidate.name === "sessions_send");
if (!tool) {
throw new Error("missing sessions_send tool");
}
const result = await tool.execute("call-run-scoped-caller", {
sessionKey: runScopedCallerKey,
message: "[TASK-COMPLETE] re-portal occupancy ready",
timeoutSeconds: 0,
});
const details = sessionsSendDetails(result.details);
expect(details.status).toBe("error");
expect(details.sessionKey).toBe(runScopedCallerKey);
expect(details.error).toContain(
"queue_message_failed reason=source_reply_delivery_mode_mismatch",
);
expect(queueMessage).not.toHaveBeenCalled();
expect(calls.some((call) => call.method === "agent")).toBe(false);
});
it("sessions_send keeps ordinary active session targets on the gateway agent path", async () => {
const calls: Array<{ method?: string; params?: unknown }> = [];
const ordinaryActiveKey = "agent:main:main";
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"ordinary-active-session",
{
queueMessage,
isStreaming: () => true,
isCompacting: () => false,
supportsTranscriptCommitWait: true,
sourceReplyDeliveryMode: "automatic",
abort: () => {},
},
ordinaryActiveKey,
);
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
return { runId: "ordinary-agent-run", status: "accepted", acceptedAt: 2000 };
}
return {};
});
const tool = createOpenClawTools({
agentSessionKey: "agent:re-portal:main",
agentChannel: "telegram",
config: {
...TEST_CONFIG,
session: {
...TEST_CONFIG.session,
agentToAgent: { maxPingPongTurns: 0 },
},
},
}).find((candidate) => candidate.name === "sessions_send");
if (!tool) {
throw new Error("missing sessions_send tool");
}
const result = await tool.execute("call-ordinary-active", {
sessionKey: ordinaryActiveKey,
message: "ordinary active target should stay gateway routed",
timeoutSeconds: 0,
});
const details = sessionsSendDetails(result.details);
expect(details.status).toBe("accepted");
expect(details.runId).toBe("ordinary-agent-run");
expect(details.sessionKey).toBe(ordinaryActiveKey);
expect(queueMessage).not.toHaveBeenCalled();
const agentCalls = calls.filter((call) => call.method === "agent");
expect(
agentCalls.some(
(call) =>
(call.params as { sessionKey?: string } | undefined)?.sessionKey === runScopedCallerKey,
),
).toBe(false);
const fallbackParams = agentCalls.find(
(call) =>
(call.params as { sessionKey?: string } | undefined)?.sessionKey === durableCallerKey,
)?.params as { inputProvenance?: { sourceSessionKey?: string }; message?: string } | undefined;
expect(fallbackParams?.message).toContain("[Inter-session message]");
expect(fallbackParams?.message).toContain("[TASK-COMPLETE] re-portal occupancy ready");
expect(fallbackParams?.inputProvenance?.sourceSessionKey).toBe(requesterKey);
expect(agentCalls).toHaveLength(1);
expect(agentParams(agentCalls[0] ?? {}).sessionKey).toBe(ordinaryActiveKey);
});
await vi.waitFor(() => {
const waitCall = calls.find(
(call) =>
call.method === "agent.wait" &&
(call.params as { runId?: string } | undefined)?.runId === "fallback-run",
);
expect(waitCall).toBeDefined();
it("sessions_send falls back from stranded cron run key to durable cron parent", async () => {
const calls: Array<{ method?: string; params?: unknown }> = [];
const runScopedCallerKey = "agent:leasing-ops:cron:monthly-utility:run:run-fast";
const durableCronCallerKey = "agent:leasing-ops:cron:monthly-utility";
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"caller-active-session",
{
queueMessage,
isStreaming: () => false,
isCompacting: () => false,
supportsTranscriptCommitWait: true,
sourceReplyDeliveryMode: "message_tool_only",
abort: () => {},
},
runScopedCallerKey,
);
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
return { runId: "durable-fallback-run", status: "accepted", acceptedAt: 2000 };
}
return {};
});
await vi.waitFor(() => {
const historyCall = calls.find(
(call) =>
call.method === "chat.history" &&
(call.params as { sessionKey?: string } | undefined)?.sessionKey === durableCallerKey,
);
expect(historyCall).toBeDefined();
const tool = createOpenClawTools({
agentSessionKey: "agent:re-portal:main",
agentChannel: "telegram",
config: {
...TEST_CONFIG,
session: {
...TEST_CONFIG.session,
agentToAgent: { maxPingPongTurns: 0 },
},
},
}).find((candidate) => candidate.name === "sessions_send");
if (!tool) {
throw new Error("missing sessions_send tool");
}
const result = await tool.execute("call-run-scoped-caller", {
sessionKey: runScopedCallerKey,
message: "[TASK-COMPLETE] re-portal occupancy ready",
timeoutSeconds: 0,
});
const details = sessionsSendDetails(result.details);
expect(details.status).toBe("accepted");
expect(details.runId).toBe("durable-fallback-run");
expect(details.sessionKey).toBe(runScopedCallerKey);
expect(queueMessage).not.toHaveBeenCalled();
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(1);
const params = agentParams(agentCalls[0] ?? {});
expect(params.sessionKey).toBe(durableCronCallerKey);
expect(params.message).toContain("[Inter-session message]");
expect(params.message).toContain("[TASK-COMPLETE] re-portal occupancy ready");
});
it("sessions_send rejects non-cron run-looking keys without durable-session fallback", async () => {
const calls: Array<{ method?: string; params?: unknown }> = [];
const runScopedCallerKey = "agent:leasing-ops:slack:channel:c-room:run:run-fast";
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"caller-active-session",
{
queueMessage,
isStreaming: () => false,
isCompacting: () => false,
supportsTranscriptCommitWait: true,
sourceReplyDeliveryMode: "message_tool_only",
abort: () => {},
},
runScopedCallerKey,
);
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
return { runId: "durable-fallback-run", status: "accepted", acceptedAt: 2000 };
}
return {};
});
const tool = createOpenClawTools({
agentSessionKey: "agent:re-portal:main",
agentChannel: "telegram",
config: {
...TEST_CONFIG,
session: {
...TEST_CONFIG.session,
agentToAgent: { maxPingPongTurns: 0 },
},
},
}).find((candidate) => candidate.name === "sessions_send");
if (!tool) {
throw new Error("missing sessions_send tool");
}
const result = await tool.execute("call-run-scoped-caller", {
sessionKey: runScopedCallerKey,
message: "[TASK-COMPLETE] re-portal occupancy ready",
timeoutSeconds: 0,
});
const details = sessionsSendDetails(result.details);
expect(details.status).toBe("error");
expect(details.sessionKey).toBe(runScopedCallerKey);
expect(details.error).toContain("queue_message_failed reason=not_streaming");
expect(queueMessage).not.toHaveBeenCalled();
expect(calls.some((call) => call.method === "agent")).toBe(false);
});
it("sessions_send preserves active delivery when transcript commit wait is unsupported", async () => {
@@ -1677,7 +1857,7 @@ describe("sessions tools", () => {
expect(calls.some((call) => call.method === "agent")).toBe(false);
});
it("sessions_send reports run-scoped fallback admission failures", async () => {
it("sessions_send reports run-scoped queue admission failures without gateway fallback", async () => {
const runScopedCallerKey = "agent:leasing-ops:cron:monthly-utility:run:run-fast";
const queueMessage = vi.fn(async () => {
throw new Error("active session ended before queued steering message was committed");
@@ -1720,7 +1900,12 @@ describe("sessions tools", () => {
expect(details.status).toBe("error");
expect(details.sessionKey).toBe(runScopedCallerKey);
expect(details.error).toContain("queue_message_failed reason=runtime_rejected");
expect(details.error).toContain("fallback_failed error=gateway request timeout for agent");
expect(details.error).not.toContain("fallback_failed");
expect(
callGatewayMock.mock.calls.some(
(call) => (call[0] as { method?: string } | undefined)?.method === "agent",
),
).toBe(false);
});
it("sessions_send preserves terminal timeouts without starting A2A", async () => {

View File

@@ -73,6 +73,15 @@ const providerEndpointPlugins = vi.hoisted(() => [
hosts: ["integrate.api.nvidia.com"],
baseUrls: ["https://integrate.api.nvidia.com/v1"],
},
{
endpointClass: "xiaomi-native",
hosts: [
"api.xiaomimimo.com",
"token-plan-ams.xiaomimimo.com",
"token-plan-cn.xiaomimimo.com",
"token-plan-sgp.xiaomimimo.com",
],
},
],
providerRequest: {
providers: {
@@ -90,6 +99,8 @@ const providerEndpointPlugins = vi.hoisted(() => [
openrouter: { family: "openrouter" },
qwen: { family: "modelstudio" },
together: { family: "together" },
xiaomi: { family: "xiaomi" },
"xiaomi-token-plan": { family: "xiaomi" },
xai: { family: "xai" },
zai: { family: "zai" },
},
@@ -104,6 +115,15 @@ vi.mock("../plugins/plugin-registry.js", () => ({
}),
}));
vi.mock("../plugins/manifest-metadata-scan.js", () => ({
listOpenClawPluginManifestMetadata: () =>
providerEndpointPlugins.map((manifest, index) => ({
pluginDir: `provider-endpoint-fixture-${index}`,
manifest,
origin: "bundled",
})),
}));
import {
listProviderAttributionPolicies,
resolveProviderAttributionHeaders,

View File

@@ -345,6 +345,8 @@ async function deliverSlackChannelAnnouncement(params: {
queueEmbeddedAgentMessageWithOutcome?: QueueEmbeddedAgentMessageWithOutcome;
sendMessage?: typeof runtimeSendMessage;
internalEvents?: AgentInternalEvent[];
sourceSessionKey?: string;
sourceChannel?: string;
sourceTool?: string;
runtimeConfig?: Record<string, unknown>;
}) {
@@ -381,6 +383,8 @@ async function deliverSlackChannelAnnouncement(params: {
bestEffortDeliver: true,
directIdempotencyKey: params.directIdempotencyKey,
internalEvents: params.internalEvents,
sourceSessionKey: params.sourceSessionKey,
sourceChannel: params.sourceChannel,
sourceTool: params.sourceTool,
});
}
@@ -4015,8 +4019,21 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
expect(sendMessage).not.toHaveBeenCalled();
});
it("directly delivers stale isolated cron run media completions", async () => {
const callGateway = createGatewayMock();
it("runs inactive isolated cron media completions through the requester agent first", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [{ text: "queued the generated image confirmation" }],
messagingToolSentTargets: [
{
tool: "sessions_send",
provider: "slack",
to: "channel:C123",
text: "The daily media workflow continued after the image callback.",
mediaUrls: ["/tmp/generated-daily.png"],
},
],
},
});
const sendMessage = createSendMessageMock();
const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true);
const result = await deliverSlackChannelAnnouncement({
@@ -4044,6 +4061,8 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
replyInstruction: "Deliver the generated image through the requester run.",
},
],
sourceSessionKey: "image_generate:task-123",
sourceChannel: "internal",
});
expectRecordFields(result, {
@@ -4051,7 +4070,71 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
path: "direct",
});
expect(queueEmbeddedAgentMessageWithOutcome).not.toHaveBeenCalled();
expect(callGateway).not.toHaveBeenCalled();
expect(callGateway).toHaveBeenCalledTimes(1);
const params = expectGatewayAgentParams(callGateway, {
sessionKey: "agent:main:cron:daily-media:run:run-123",
deliver: true,
channel: "slack",
accountId: "acct-1",
to: "channel:C123",
idempotencyKey: "announce-stale-cron-media",
});
expectRecordFields(params.inputProvenance, {
kind: "inter_session",
sourceSessionKey: "image_generate:task-123",
sourceChannel: "internal",
sourceTool: "image_generate",
});
expect(sendMessage).not.toHaveBeenCalled();
});
it("directly delivers inactive isolated cron media only after requester-agent fallback misses media", async () => {
const callGateway = createGatewayMock();
const sendMessage = createSendMessageMock();
const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true);
const result = await deliverSlackChannelAnnouncement({
callGateway,
sendMessage,
queueEmbeddedAgentMessageWithOutcome,
sessionId: "stale-cron-run-session",
isActive: false,
requesterSessionKey: "agent:main:cron:daily-media:run:run-123",
expectsCompletionMessage: true,
directIdempotencyKey: "announce-stale-cron-media-fallback",
sourceTool: "image_generate",
internalEvents: [
{
type: "task_completion",
source: "image_generation",
childSessionKey: "image_generate:task-123",
childSessionId: "task-123",
announceType: "image generation task",
taskLabel: "daily media",
status: "ok",
statusLabel: "completed successfully",
result: "Generated 1 image.\nMEDIA:/tmp/generated-daily.png",
mediaUrls: ["/tmp/generated-daily.png"],
replyInstruction: "Deliver the generated image through the requester run.",
},
],
sourceSessionKey: "image_generate:task-123",
sourceChannel: "internal",
});
expectRecordFields(result, {
delivered: true,
path: "direct",
});
expect(queueEmbeddedAgentMessageWithOutcome).not.toHaveBeenCalled();
expect(callGateway).toHaveBeenCalledTimes(1);
expectGatewayAgentParams(callGateway, {
sessionKey: "agent:main:cron:daily-media:run:run-123",
deliver: true,
channel: "slack",
accountId: "acct-1",
to: "channel:C123",
idempotencyKey: "announce-stale-cron-media-fallback",
});
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
channel: "slack",
@@ -4059,7 +4142,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
to: "channel:C123",
content: "The generated image is ready.",
mediaUrls: ["/tmp/generated-daily.png"],
idempotencyKey: "announce-stale-cron-media:generated-media-direct",
idempotencyKey: "announce-stale-cron-media-fallback:generated-media-direct",
}),
);
});

View File

@@ -1387,7 +1387,8 @@ async function sendSubagentAnnounceDirectly(params: {
if (
params.expectsCompletionMessage &&
isCronRunSessionKey(canonicalRequesterSessionKey) &&
!resolveRequesterSessionActivity(canonicalRequesterSessionKey).isActive
!resolveRequesterSessionActivity(canonicalRequesterSessionKey).isActive &&
!agentMediatedCompletion
) {
const generatedMediaDelivery = await tryGeneratedMediaDirectDelivery();
if (generatedMediaDelivery) {

View File

@@ -180,6 +180,7 @@ export function createSubagentRunManager(params: {
stopSweeper(): void;
resumeSubagentRun(runId: string): void;
clearPendingLifecycleError(runId: string): void;
clearPendingLifecycleTimeout(runId: string): void;
resolveSubagentWaitTimeoutMs(cfg: OpenClawConfig, runTimeoutSeconds?: number): number;
scheduleOrphanRecovery(args?: { delayMs?: number; maxRetries?: number }): void;
resolveSubagentSessionCompletion(args: {
@@ -264,6 +265,8 @@ export function createSubagentRunManager(params: {
waitTerminalOutcome?.reason === "aborted" || waitTerminalOutcome?.reason === "cancelled";
const waitStatus = waitTerminalOutcome?.status ?? wait.status;
if (wait.yielded === true && waitStatus !== "timeout" && !waitBlocked) {
params.clearPendingLifecycleError(runId);
params.clearPendingLifecycleTimeout(runId);
if (
markSubagentRunPausedAfterYield({
entry,

View File

@@ -2000,6 +2000,185 @@ describe("subagent registry seam flow", () => {
expect(replacement?.endedAt).toBeUndefined();
});
it("keeps yield terminals paused when the lifecycle event also signals abort (#92448)", async () => {
// sessions_yield ends the turn by aborting the run signal, so a depth-1
// subagent's yield terminal can arrive carrying yielded plus aborted (or
// stopReason="aborted"). The event handler must still pause the run, not
// settle it `cancelled` and deliver a false notice to the requester.
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent.wait") {
return { status: "pending" };
}
return {};
});
const cases = [
{ runId: "run-yield-stopreason-aborted", extra: { stopReason: "aborted" } },
{ runId: "run-yield-aborted-flag", extra: { aborted: true } },
];
for (const testCase of cases) {
mod.registerSubagentRun({
runId: testCase.runId,
childSessionKey: `agent:main:subagent:${testCase.runId}`,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "wait for child continuation",
cleanup: "keep",
});
const lastOnAgentEventCall = mocks.onAgentEvent.mock.calls[
mocks.onAgentEvent.mock.calls.length - 1
] as unknown as
| [(evt: { runId: string; stream: string; data: Record<string, unknown> }) => void]
| undefined;
const lifecycleHandler = lastOnAgentEventCall?.[0];
expect(lifecycleHandler).toBeTypeOf("function");
lifecycleHandler?.({
runId: testCase.runId,
stream: "lifecycle",
data: {
phase: "end",
startedAt: 111,
endedAt: 222,
yielded: true,
...testCase.extra,
},
});
await waitForFast(() => {
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === testCase.runId);
expect(run?.pauseReason).toBe("sessions_yield");
expect(run?.outcome?.status).not.toBe("error");
});
}
// Paused, never killed → no farewell/cancellation notice reaches the requester.
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
});
it("cancels a pending grace timer when a yield follows an intermediate aborted terminal (#92448)", async () => {
// An earlier aborted terminal schedules a deferred kill grace timer; a
// following yield must clear it, or it fires and settles the now-paused run.
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent.wait") {
return { status: "pending" };
}
return {};
});
mod.registerSubagentRun({
runId: "run-yield-after-pending-timeout",
childSessionKey: "agent:main:subagent:pending-timeout",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "wait for child continuation",
cleanup: "keep",
});
const lastOnAgentEventCall = mocks.onAgentEvent.mock.calls[
mocks.onAgentEvent.mock.calls.length - 1
] as unknown as
| [(evt: { runId: string; stream: string; data: Record<string, unknown> }) => void]
| undefined;
const lifecycleHandler = lastOnAgentEventCall?.[0];
expect(lifecycleHandler).toBeTypeOf("function");
// Intermediate aborted terminal → schedules the deferred kill grace timer.
lifecycleHandler?.({
runId: "run-yield-after-pending-timeout",
stream: "lifecycle",
data: { phase: "end", startedAt: 111, endedAt: 222, aborted: true },
});
// Yield terminal → must pause and cancel the pending grace timer.
lifecycleHandler?.({
runId: "run-yield-after-pending-timeout",
stream: "lifecycle",
data: { phase: "end", startedAt: 111, endedAt: 333, yielded: true },
});
await waitForFast(() => {
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-yield-after-pending-timeout");
expect(run?.pauseReason).toBe("sessions_yield");
});
// Advancing well past the 15s grace window must not undo the pause.
await vi.advanceTimersByTimeAsync(60_000);
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-yield-after-pending-timeout");
expect(run?.pauseReason).toBe("sessions_yield");
expect(run?.outcome?.status).not.toBe("error");
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
});
it("cancels a pending grace timer when agent.wait observes the yield after an aborted terminal (#92448)", async () => {
let resolveWait: (value: {
status: "ok";
startedAt: number;
endedAt: number;
yielded: true;
}) => void = () => {};
const waitResult = new Promise<{
status: "ok";
startedAt: number;
endedAt: number;
yielded: true;
}>((resolve) => {
resolveWait = resolve;
});
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent.wait") {
return waitResult;
}
return {};
});
mod.registerSubagentRun({
runId: "run-wait-yield-after-pending-timeout",
childSessionKey: "agent:main:subagent:pending-wait-timeout",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "wait for child continuation through wait",
cleanup: "keep",
});
const lastOnAgentEventCall = mocks.onAgentEvent.mock.calls[
mocks.onAgentEvent.mock.calls.length - 1
] as unknown as
| [(evt: { runId: string; stream: string; data: Record<string, unknown> }) => void]
| undefined;
const lifecycleHandler = lastOnAgentEventCall?.[0];
expect(lifecycleHandler).toBeTypeOf("function");
lifecycleHandler?.({
runId: "run-wait-yield-after-pending-timeout",
stream: "lifecycle",
data: { phase: "end", startedAt: 111, endedAt: 222, aborted: true },
});
resolveWait({ status: "ok", startedAt: 111, endedAt: 333, yielded: true });
await waitForFast(() => {
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-wait-yield-after-pending-timeout");
expect(run?.pauseReason).toBe("sessions_yield");
});
await vi.advanceTimersByTimeAsync(60_000);
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-wait-yield-after-pending-timeout");
expect(run?.pauseReason).toBe("sessions_yield");
expect(run?.outcome?.status).not.toBe("timeout");
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
});
it("announces blocked agent.wait snapshots as errors instead of success", async () => {
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent.wait") {

View File

@@ -480,7 +480,7 @@ function schedulePendingLifecycleTimeout(params: {
if (!entry) {
return;
}
if (entry.outcome?.status === "ok") {
if (entry.outcome?.status === "ok" || entry.pauseReason === "sessions_yield") {
return;
}
const completionParams = {
@@ -1106,6 +1106,25 @@ function ensureListener() {
});
return;
}
// sessions_yield ends the turn by aborting the run signal, so a yielded
// terminal can also look aborted. An explicit yield is authoritative — pause,
// don't kill — else the tracking task settles `cancelled` with a false notice (#92448).
if (evt.data?.yielded === true) {
// Drop any grace timer from an earlier aborted/error terminal so it can't
// later fire and settle this now-paused run with a false notice.
clearPendingLifecycleError(evt.runId);
clearPendingLifecycleTimeout(evt.runId);
if (
markSubagentRunPausedAfterYield({
entry,
endedAt,
startedAt: startedAt ?? entry.startedAt,
})
) {
persistSubagentRuns();
}
return;
}
if (isAbortedAgentStopReason(stopReason)) {
clearPendingLifecycleError(evt.runId);
clearPendingLifecycleTimeout(evt.runId);
@@ -1154,18 +1173,6 @@ function ensureListener() {
});
return;
}
if (evt.data?.yielded === true) {
if (
markSubagentRunPausedAfterYield({
entry,
endedAt,
startedAt: startedAt ?? entry.startedAt,
})
) {
persistSubagentRuns();
}
return;
}
clearPendingLifecycleError(evt.runId);
clearPendingLifecycleTimeout(evt.runId);
const completionParams = {
@@ -1203,6 +1210,7 @@ const subagentRunManager = createSubagentRunManager({
stopSweeper,
resumeSubagentRun,
clearPendingLifecycleError,
clearPendingLifecycleTimeout,
resolveSubagentWaitTimeoutMs,
scheduleOrphanRecovery: (args) => scheduleSubagentOrphanRecovery(args),
resolveSubagentSessionCompletion,

View File

@@ -21,6 +21,7 @@ import {
toAgentStoreSessionKey,
} from "../../routing/session-key.js";
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
import { isCronRunSessionKey, parseAgentSessionKey } from "../../sessions/session-key-utils.js";
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
import { stripFormattedReasoningMessage } from "../../shared/text/formatted-reasoning-message.js";
import {
@@ -30,6 +31,7 @@ import {
import { listAgentIds } from "../agent-scope.js";
import {
type EmbeddedAgentQueueMessageOptions,
type EmbeddedAgentQueueMessageOutcome,
formatEmbeddedAgentQueueFailureSummary,
queueEmbeddedAgentMessageWithOutcomeAsync,
resolveActiveEmbeddedRunSessionId,
@@ -92,11 +94,6 @@ function normalizeSessionsSendArguments(args: unknown): Record<string, unknown>
return params;
}
function resolveRunScopedFallbackSessionKey(sessionKey: string): string | undefined {
const match = /^(agent:[^:]+:.+):run:[^:]+$/.exec(sessionKey.trim());
return match?.[1];
}
function resolveConfiguredAgentMainSessionKey(params: {
cfg: OpenClawConfig;
agentId: string;
@@ -204,13 +201,51 @@ function isPendingErrorAgentWaitTimeout(result: AgentWaitResult): boolean {
);
}
function isRunScopedAgentSessionKey(sessionKey: string): boolean {
const parsed = parseAgentSessionKey(normalizeOptionalString(sessionKey));
return Boolean(parsed && /(?:^|:)run:[^:]+(?::|$)/.test(parsed.rest));
}
function resolveCronRunScopedFallbackSessionKey(sessionKey: string): string | undefined {
const normalizedSessionKey = normalizeOptionalString(sessionKey);
if (!normalizedSessionKey || !isCronRunSessionKey(normalizedSessionKey)) {
return undefined;
}
const parsed = parseAgentSessionKey(normalizedSessionKey);
if (!parsed) {
return undefined;
}
const runMarker = ":run:";
const runMarkerIndex = parsed.rest.lastIndexOf(runMarker);
if (runMarkerIndex <= 0) {
return undefined;
}
const runId = parsed.rest.slice(runMarkerIndex + runMarker.length);
if (!runId || runId.includes(":")) {
return undefined;
}
const fallbackRest = parsed.rest.slice(0, runMarkerIndex);
if (!fallbackRest) {
return undefined;
}
return `agent:${parsed.agentId}:${fallbackRest}`;
}
function shouldFallbackCronRunScopedActiveDelivery(
outcome: EmbeddedAgentQueueMessageOutcome,
): boolean {
return (
!outcome.queued && (outcome.reason === "not_streaming" || outcome.reason === "no_active_run")
);
}
async function startAgentRun(params: {
callGateway: GatewayCaller;
runId: string;
sendParams: Record<string, unknown>;
sessionKey: string;
deliveryTimeoutMs?: number;
allowActiveRunQueueFallback?: boolean;
allowActiveRunQueueDelivery?: boolean;
}): Promise<
| {
ok: true;
@@ -222,15 +257,13 @@ async function startAgentRun(params: {
| { ok: false; result: ReturnType<typeof jsonResult> }
> {
try {
const activeRunSessionId = params.allowActiveRunQueueFallback
? resolveActiveEmbeddedRunSessionId(params.sessionKey)
: undefined;
const fallbackSessionKey = activeRunSessionId
? resolveRunScopedFallbackSessionKey(params.sessionKey)
: undefined;
const activeRunSessionId =
params.allowActiveRunQueueDelivery && isRunScopedAgentSessionKey(params.sessionKey)
? resolveActiveEmbeddedRunSessionId(params.sessionKey)
: undefined;
const messageText =
typeof params.sendParams.message === "string" ? params.sendParams.message : undefined;
if (activeRunSessionId && fallbackSessionKey && messageText) {
if (activeRunSessionId && messageText) {
const sourceReplyDeliveryMode =
params.sendParams.sourceReplyDeliveryMode === "automatic" ||
params.sendParams.sourceReplyDeliveryMode === "message_tool_only"
@@ -260,7 +293,8 @@ async function startAgentRun(params: {
if (queueOutcome.queued) {
return { ok: true, runId: params.runId, activeRunQueue: true };
}
try {
const fallbackSessionKey = resolveCronRunScopedFallbackSessionKey(params.sessionKey);
if (fallbackSessionKey && shouldFallbackCronRunScopedActiveDelivery(queueOutcome)) {
const response = await params.callGateway<{ runId: string }>({
method: "agent",
params: {
@@ -277,13 +311,10 @@ async function startAgentRun(params: {
a2aSessionKey: fallbackSessionKey,
a2aDisplayKey: fallbackSessionKey,
};
} catch (err) {
const queueSummary =
formatEmbeddedAgentQueueFailureSummary(queueOutcome) ?? "active run queue rejected";
throw new Error(`${queueSummary}; fallback_failed error=${formatErrorMessage(err)}`, {
cause: err,
});
}
const queueSummary =
formatEmbeddedAgentQueueFailureSummary(queueOutcome) ?? "active run queue rejected";
throw new Error(queueSummary);
}
const response = await params.callGateway<{ runId: string }>({
method: "agent",
@@ -643,7 +674,7 @@ export function createSessionsSendTool(opts?: {
sendParams,
sessionKey: displayKey,
deliveryTimeoutMs: announceTimeoutMs,
allowActiveRunQueueFallback: true,
allowActiveRunQueueDelivery: true,
});
if (!start.ok) {
return start.result;

View File

@@ -73,6 +73,8 @@ const {
dispatchInboundMessageWithBufferedDispatcher,
withReplyDispatcher,
} = await import("./dispatch.js");
const { clearReplyUsageStateForTest, recordReplyUsageState } =
await import("./reply/reply-usage-state.js");
function createDispatcher(record: string[]): ReplyDispatcher {
return {
@@ -110,6 +112,7 @@ function requireReplyDispatcherOptions(index = 0): Parameters<CreateReplyDispatc
describe("withReplyDispatcher", () => {
beforeEach(() => {
vi.clearAllMocks();
clearReplyUsageStateForTest();
hoisted.finalizeInboundContextMock.mockImplementation((ctx: unknown) => ctx);
hoisted.deriveInboundMessageHookContextMock.mockReturnValue({
channelId: "threads",
@@ -424,6 +427,57 @@ describe("withReplyDispatcher", () => {
);
});
it("correlates reply_payload_sending usageState with the generated run id", async () => {
const usageState = { provider: "openai", model: "gpt-5.5" };
const runReplyPayloadSending = vi.fn(async ({ payload }: { payload: { text?: string } }) => ({
payload,
}));
hoisted.getGlobalHookRunnerMock.mockReturnValue({
hasHooks: vi.fn((hookName?: string) => hookName === "reply_payload_sending"),
runMessageSending: vi.fn(async () => undefined),
runReplyPayloadSending,
});
hoisted.createReplyDispatcherMock.mockReturnValueOnce(createDispatcher([]));
hoisted.dispatchReplyFromConfigMock.mockImplementationOnce(async ({ replyOptions }) => {
replyOptions?.onAgentRunStart?.("generated-run");
recordReplyUsageState("generated-run", usageState);
return { text: "ok" };
});
await dispatchInboundMessageWithDispatcher({
ctx: buildTestCtx({ Surface: "telegram", SessionKey: "agent:test:session" }),
cfg: {} as OpenClawConfig,
dispatcherOptions: {
deliver: async () => undefined,
},
replyResolver: async () => ({ text: "ok" }),
});
const dispatcherOptions = requireReplyDispatcherOptions();
if (!dispatcherOptions?.beforeDeliver) {
throw new Error("expected beforeDeliver hook");
}
await dispatcherOptions.beforeDeliver({ text: "original reply" }, { kind: "final" });
expect(runReplyPayloadSending).toHaveBeenCalledWith(
{
payload: { text: "original reply" },
kind: "final",
channel: "telegram",
sessionKey: "agent:test:session",
runId: "generated-run",
usageState,
},
{
accountId: "acct-1",
channelId: "threads",
conversationId: "conv-1",
runId: "generated-run",
},
);
});
it("runs message_sending after reply_payload_sending for inbound dispatcher delivery", async () => {
const runReplyPayloadSending = vi.fn(async ({ payload }: { payload: { text?: string } }) => ({
payload: {

View File

@@ -35,6 +35,7 @@ import {
} from "./reply/reply-dispatcher.js";
import type { ReplyDispatcher } from "./reply/reply-dispatcher.types.js";
import { runReplyPayloadSendingHook } from "./reply/reply-payload-sending-hook.js";
import { consumeReplyUsageState } from "./reply/reply-usage-state.js";
import type { FinalizedMsgContext, MsgContext } from "./templating.js";
import type { GetReplyOptions, ReplyPayload } from "./types.js";
@@ -51,6 +52,10 @@ type ForegroundReplyFenceSnapshot = {
generation: number;
};
type ReplyPayloadRunState = {
runId?: string;
};
const foregroundReplyFenceByKey = new Map<string, ForegroundReplyFenceState>();
const replyPayloadSendingDispatchers = new WeakSet<ReplyDispatcher>();
@@ -348,36 +353,52 @@ function buildMessageSendingBeforeDeliver(
function buildReplyPayloadSendingBeforeDeliver(
ctx: MsgContext | FinalizedMsgContext,
opts?: { runId?: string },
runState: ReplyPayloadRunState,
): ReplyDispatchBeforeDeliver {
const finalized = finalizeInboundContext(ctx);
const hookCtx = deriveInboundMessageHookContext(finalized);
return async (payload: ReplyPayload, info): Promise<ReplyPayload | null> => {
const runId = runState.runId;
const hookedPayload = await runReplyPayloadSendingHook({
payload,
kind: info.kind,
channel: finalized.Surface ?? finalized.Provider,
sessionKey: finalized.SessionKey,
runId: opts?.runId,
runId,
usageState: consumeReplyUsageState(runId),
context: {
...toPluginMessageContext(hookCtx),
runId: opts?.runId,
runId,
},
});
return hookedPayload && hasOutboundReplyContent(hookedPayload) ? hookedPayload : null;
};
}
function bindReplyPayloadRunState(
replyOptions: Omit<GetReplyOptions, "onBlockReply"> | undefined,
runState: ReplyPayloadRunState,
): Omit<GetReplyOptions, "onBlockReply"> {
const onAgentRunStart = replyOptions?.onAgentRunStart;
return {
...replyOptions,
onAgentRunStart: (runId) => {
runState.runId = runId;
onAgentRunStart?.(runId);
},
};
}
function installReplyPayloadSendingBeforeDeliver(
dispatcher: ReplyDispatcher,
ctx: MsgContext | FinalizedMsgContext,
opts?: { runId?: string },
runState: ReplyPayloadRunState,
): void {
if (replyPayloadSendingDispatchers.has(dispatcher)) {
return;
}
const beforeDeliver = buildReplyPayloadSendingBeforeDeliver(ctx, opts);
const beforeDeliver = buildReplyPayloadSendingBeforeDeliver(ctx, runState);
if (!beforeDeliver || !dispatcher.appendBeforeDeliver) {
return;
}
@@ -481,8 +502,13 @@ export async function dispatchInboundMessage(params: {
replyOptions?: Omit<GetReplyOptions, "onBlockReply">;
replyResolver?: GetReplyFromConfig;
onSessionMetadataChanges?: (changes: CommandSessionMetadataChange[]) => void;
replyPayloadRunState?: ReplyPayloadRunState;
}): Promise<DispatchInboundResult> {
const replyOptions = applyRuntimeToolsAllow(params.replyOptions, params.toolsAllow);
const replyPayloadRunState = params.replyPayloadRunState ?? {
runId: replyOptions?.runId,
};
const replyOptionsWithRunState = bindReplyPayloadRunState(replyOptions, replyPayloadRunState);
const finalized = measureDiagnosticsTimelineSpanSync(
"auto_reply.finalize_context",
() => finalizeInboundContext(params.ctx),
@@ -501,9 +527,7 @@ export async function dispatchInboundMessage(params: {
source: "dispatchInboundMessage",
});
}
installReplyPayloadSendingBeforeDeliver(params.dispatcher, finalized, {
runId: replyOptions?.runId,
});
installReplyPayloadSendingBeforeDeliver(params.dispatcher, finalized, replyPayloadRunState);
const result = await withReplyDispatcher({
dispatcher: params.dispatcher,
run: () =>
@@ -514,7 +538,7 @@ export async function dispatchInboundMessage(params: {
ctx: finalized,
cfg: params.cfg,
dispatcher: params.dispatcher,
replyOptions,
replyOptions: replyOptionsWithRunState,
replyResolver: params.replyResolver,
onSessionMetadataChanges: params.onSessionMetadataChanges,
}),
@@ -541,9 +565,13 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: {
const finalized = finalizeInboundContext(params.ctx);
const foregroundReplyFence = beginForegroundReplyFence(finalized);
const silentReplyContext = resolveDispatcherSilentReplyContext(finalized, params.cfg);
const replyPayloadBeforeDeliver = buildReplyPayloadSendingBeforeDeliver(finalized, {
const replyPayloadRunState = {
runId: params.replyOptions?.runId,
});
};
const replyPayloadBeforeDeliver = buildReplyPayloadSendingBeforeDeliver(
finalized,
replyPayloadRunState,
);
const globalBeforeDeliver = combineBeforeDeliverHooks(
replyPayloadBeforeDeliver,
buildMessageSendingBeforeDeliver(finalized),
@@ -603,6 +631,7 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: {
...params.replyOptions,
...replyOptions,
},
replyPayloadRunState,
onSessionMetadataChanges: params.onSessionMetadataChanges,
});
} finally {
@@ -635,9 +664,13 @@ export async function dispatchInboundMessageWithDispatcher(params: {
replyResolver?: GetReplyFromConfig;
}): Promise<DispatchInboundResult> {
const silentReplyContext = resolveDispatcherSilentReplyContext(params.ctx, params.cfg);
const replyPayloadBeforeDeliver = buildReplyPayloadSendingBeforeDeliver(params.ctx, {
const replyPayloadRunState = {
runId: params.replyOptions?.runId,
});
};
const replyPayloadBeforeDeliver = buildReplyPayloadSendingBeforeDeliver(
params.ctx,
replyPayloadRunState,
);
const globalBeforeDeliver = combineBeforeDeliverHooks(
replyPayloadBeforeDeliver,
buildMessageSendingBeforeDeliver(params.ctx),
@@ -658,5 +691,6 @@ export async function dispatchInboundMessageWithDispatcher(params: {
toolsAllow: params.toolsAllow,
replyResolver: params.replyResolver,
replyOptions: params.replyOptions,
replyPayloadRunState,
});
}

View File

@@ -15,6 +15,8 @@ import {
formatEmbeddedAgentQueueFailureSummary,
queueEmbeddedAgentMessageWithOutcomeAsync,
} from "../../agents/embedded-agent-runner/runs.js";
import { resolveFastModeState } from "../../agents/fast-mode.js";
import { resolveAgentIdentity } from "../../agents/identity.js";
import { resolveModelAuthMode } from "../../agents/model-auth.js";
import { isCliProvider } from "../../agents/model-selection.js";
import { deriveContextPromptTokens, hasNonzeroUsage, normalizeUsage } from "../../agents/usage.js";
@@ -40,6 +42,7 @@ import {
} from "../../infra/diagnostic-trace-context.js";
import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js";
import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
@@ -67,6 +70,9 @@ import type { OriginatingChannelType, TemplateContext } from "../templating.js";
import { resolveResponseUsageMode, type VerboseLevel } from "../thinking.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { buildUsageContract } from "../usage-bar/contract.js";
import { loadUsageBarTemplate } from "../usage-bar/template.js";
import { renderUsageBar } from "../usage-bar/translator.js";
import {
buildKnownAgentRunFailureReplyPayload,
runAgentTurnWithFallback,
@@ -117,6 +123,7 @@ import { createReplyMediaContext } from "./reply-media-paths.js";
import { replyRunRegistry, type ReplyOperation } from "./reply-run-registry.js";
import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js";
import { admitReplyTurn, resolveReplyTurnKind } from "./reply-turn-admission.js";
import { recordReplyUsageState } from "./reply-usage-state.js";
import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js";
import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-run-accounting.js";
import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js";
@@ -1735,6 +1742,77 @@ export async function runReplyAgent(params: {
const modelUsed = runResult.meta?.agentMeta?.model ?? fallbackModel ?? defaultModel;
const providerUsed =
runResult.meta?.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider;
let replyUsageState: PluginHookReplyUsageState | undefined;
{
const winnerProvider = runResult.meta?.executionTrace?.winnerProvider ?? providerUsed;
const winnerModel = runResult.meta?.executionTrace?.winnerModel ?? modelUsed;
const ctxTokens = runResult.meta?.agentMeta?.contextTokens;
const compactions = runResult.meta?.agentMeta?.compactionCount;
const lastCallUsage = runResult.meta?.agentMeta?.lastCallUsage;
replyUsageState = {
provider: providerUsed,
model: modelUsed,
resolvedRef: winnerProvider && winnerModel ? `${winnerProvider}/${winnerModel}` : undefined,
reasoningEffort:
typeof followupRun.run.thinkLevel === "string" ? followupRun.run.thinkLevel : undefined,
fastMode: resolveFastModeState({
cfg,
provider: providerUsed ?? "",
model: modelUsed ?? "",
agentId: followupRun.run.agentId,
sessionEntry: activeSessionEntry,
}).enabled,
fallbackUsed: runResult.meta?.executionTrace?.fallbackUsed === true,
agentId: followupRun.run.agentId,
sessionId: followupRun.run.sessionId,
chatType: typeof sessionCtx.ChatType === "string" ? sessionCtx.ChatType : undefined,
authMode: runResult.meta?.requestShaping?.authMode ?? undefined,
overrideSource: activeSessionEntry?.modelOverrideSource ?? undefined,
requested:
followupRun.run.provider && followupRun.run.model
? `${followupRun.run.provider}/${followupRun.run.model}`
: undefined,
turnUsd: usage
? estimateUsageCost({
usage,
cost: resolveModelCostConfig({
provider: providerUsed,
model: modelUsed,
config: cfg,
}),
})
: undefined,
durationMs: Date.now() - runStartedAt,
identity: resolveAgentIdentity(cfg, followupRun.run.agentId),
compactionCount: typeof compactions === "number" ? compactions : undefined,
contextTokenBudget:
typeof ctxTokens === "number" && Number.isFinite(ctxTokens) ? ctxTokens : undefined,
contextUsedTokens:
typeof promptTokens === "number" && Number.isFinite(promptTokens)
? promptTokens
: undefined,
usage: usage
? {
input: usage.input,
output: usage.output,
cacheRead: usage.cacheRead,
cacheWrite: usage.cacheWrite,
total: usage.total,
}
: undefined,
lastUsage: lastCallUsage
? {
input: lastCallUsage.input,
output: lastCallUsage.output,
cacheRead: lastCallUsage.cacheRead,
cacheWrite: lastCallUsage.cacheWrite,
total: lastCallUsage.total,
}
: undefined,
};
recordReplyUsageState(runId, replyUsageState);
}
const verboseEnabled = resolvedVerboseLevel !== "off";
const preserveUserFacingSessionState = shouldPreserveUserFacingSessionStateForInputProvenance(
followupRun.run.inputProvenance,
@@ -2103,7 +2181,16 @@ export async function runReplyAgent(params: {
showCost,
costConfig,
});
if (formatted && responseUsageMode === "full" && sessionKey) {
const usageTemplate =
responseUsageMode === "full" && replyUsageState
? loadUsageBarTemplate(cfg.messages?.usageTemplate)
: undefined;
const renderedUsageLine = usageTemplate
? renderUsageBar(usageTemplate, buildUsageContract(replyUsageState, replyToChannel))
: undefined;
if (renderedUsageLine) {
formatted = renderedUsageLine;
} else if (formatted && responseUsageMode === "full" && sessionKey) {
formatted = `${formatted} · session \`${sessionKey}\``;
}
if (formatted) {

View File

@@ -29,6 +29,16 @@ const modelProviderAuthMocks = vi.hoisted(() => {
return state;
});
const normalizeProviderModelIdWithRuntimeMock = vi.hoisted(() => vi.fn());
const pluginMetadataMocks = vi.hoisted(() => ({
snapshot: undefined as
| {
plugins: unknown[];
owners: {
cliBackends: Map<string, string>;
};
}
| undefined,
}));
const MODELS_ADD_DEPRECATED_TEXT =
"⚠️ /models add is deprecated. Use /models to browse providers and /model to switch models.";
@@ -83,6 +93,10 @@ vi.mock("../../agents/provider-model-normalization.runtime.js", () => ({
normalizeProviderModelIdWithRuntimeMock(params),
}));
vi.mock("../../plugins/current-plugin-metadata-snapshot.js", () => ({
getCurrentPluginMetadataSnapshot: () => pluginMetadataMocks.snapshot,
}));
const telegramModelsTestPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "telegram",
@@ -160,6 +174,7 @@ beforeEach(() => {
modelAuthLabelMocks.resolveModelAuthLabel.mockReset();
modelAuthLabelMocks.resolveModelAuthLabel.mockReturnValue(undefined);
normalizeProviderModelIdWithRuntimeMock.mockReset();
pluginMetadataMocks.snapshot = undefined;
modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic", "google", "openai"]);
modelProviderAuthMocks.createProviderAuthChecker.mockClear();
const registry = createTestRegistry([
@@ -252,6 +267,12 @@ function firstAuthCheckerParams() {
return modelProviderAuthMocks.createProviderAuthChecker.mock.calls[0]?.[0];
}
function preparedAuthCheckerParams() {
return modelProviderAuthMocks.createProviderAuthChecker.mock.calls
.map(([params]) => params)
.find((params) => params.allowPreparedRuntimeAuth === true);
}
describe("handleModelsCommand", () => {
it("shows a simple providers menu on text surfaces", async () => {
const result = await handleModelsCommand(buildParams("/models"), true);
@@ -264,7 +285,7 @@ describe("handleModelsCommand", () => {
expect(result?.reply?.text).toContain("Use: /models <provider>");
expect(result?.reply?.text).toContain("Switch: /model <provider/model>");
expect(result?.reply?.text).not.toContain("Add: /models add");
const authCheckerParams = firstAuthCheckerParams();
const authCheckerParams = preparedAuthCheckerParams();
expect(authCheckerParams?.workspaceDir).toBe("/tmp");
});
@@ -272,9 +293,10 @@ describe("handleModelsCommand", () => {
await handleModelsCommand(buildParams("/models"), true);
expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.readOnly).toBe(true);
const authCheckerParams = firstAuthCheckerParams();
const authCheckerParams = preparedAuthCheckerParams();
expect(authCheckerParams?.allowPluginSyntheticAuth).toBe(false);
expect(authCheckerParams?.discoverExternalCliAuth).toBe(false);
expect(authCheckerParams?.allowPreparedRuntimeAuth).toBe(true);
});
it("does not block default browse when read-only catalog loading is slow", async () => {
@@ -302,6 +324,25 @@ describe("handleModelsCommand", () => {
expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.readOnly).toBe(false);
});
it("reuses the current plugin metadata snapshot for read-only catalog loading", async () => {
const metadataSnapshot = {
plugins: [],
owners: {
cliBackends: new Map<string, string>(),
},
};
pluginMetadataMocks.snapshot = metadataSnapshot;
await handleModelsCommand(buildParams("/models"), true);
expect(modelCatalogMocks.loadModelCatalog).toHaveBeenCalledWith(
expect.objectContaining({
readOnly: true,
metadataSnapshot,
}),
);
});
it("hides unauthenticated providers by default and keeps all as explicit browse", async () => {
modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic"]);
@@ -375,7 +416,7 @@ describe("handleModelsCommand", () => {
true,
);
expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.readOnly).toBe(false);
expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.readOnly).toBe(true);
expect(result?.reply?.text).toContain("- openai (2)");
expect(result?.reply?.text).toContain("- vllm (2)");
expect(result?.reply?.text).not.toContain("- anthropic");
@@ -449,6 +490,50 @@ describe("handleModelsCommand", () => {
]);
});
it("does not treat standalone CLI backends as canonical provider aliases", async () => {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupRegistry: () => ({
providers: [],
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
}),
resolveRuntimeCliBackends: () => [
{
id: "acme-cli",
pluginId: "acme",
config: { command: "acme" },
bundleMcp: false,
},
],
});
pluginMetadataMocks.snapshot = {
plugins: [],
owners: {
cliBackends: new Map([["acme-cli", "acme"]]),
},
};
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
{ provider: "anthropic", id: "claude-opus-4-7", name: "Claude Opus 4.7" },
{ provider: "acme-cli", id: "acme-model", name: "Acme Model" },
]);
modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic", "acme-cli"]);
const data = await buildModelsProviderData({
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-7" },
models: {
"anthropic/*": {},
},
},
},
} as OpenClawConfig);
expect(data.byProvider.has("acme-cli")).toBe(false);
});
it("keeps non-CLI configured provider model lists scoped to user config", async () => {
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
{ provider: "claude-cli", id: "claude-opus-4-7", name: "Claude Opus 4.7" },

View File

@@ -15,9 +15,8 @@ import { resolveModelAuthLabel } from "../../agents/model-auth-label.js";
import { loadModelCatalogForBrowse } from "../../agents/model-catalog-browse.js";
import { resolveVisibleModelCatalog } from "../../agents/model-catalog-visibility.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import { isModelPickerVisibleProvider } from "../../agents/model-picker-visibility.js";
import { isRetiredModelPickerProvider } from "../../agents/model-picker-visibility.js";
import { createProviderAuthChecker } from "../../agents/model-provider-auth.js";
import { isCliRuntimeProvider } from "../../agents/model-runtime-aliases.js";
import {
buildModelAliasIndex,
normalizeProviderId,
@@ -34,6 +33,7 @@ import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js";
import { resolveAgentRuntimeLabel } from "../../status/agent-runtime-label.js";
import type { ReplyPayload } from "../types.js";
import { rejectUnauthorizedCommand } from "./command-gates.js";
@@ -78,15 +78,14 @@ type ParsedModelsCommand =
};
function isModelsBrowseVisibleProvider(provider: string): boolean {
const normalized = normalizeProviderId(provider);
return (
isCliRuntimeProvider(normalized, { includeSetupRegistry: true }) ||
isModelPickerVisibleProvider(normalized)
);
return !isRetiredModelPickerProvider(provider);
}
function usesUnfilteredCatalogModels(provider: string): boolean {
return isCliRuntimeProvider(provider, { includeSetupRegistry: true });
function usesUnfilteredCatalogModels(
provider: string,
cliRuntimeProviders: ReadonlySet<string>,
): boolean {
return cliRuntimeProviders.has(normalizeProviderId(provider));
}
function normalizeRuntimeChoiceId(runtime: string | undefined): string {
@@ -155,11 +154,24 @@ export async function buildModelsProviderData(
cfg,
agentId,
});
const workspaceDir =
options.workspaceDir ??
(agentId ? resolveAgentWorkspaceDir(cfg, agentId) : undefined) ??
resolveDefaultAgentWorkspaceDir();
const metadataSnapshot = getCurrentPluginMetadataSnapshot({
config: cfg,
workspaceDir,
env: process.env,
allowScopedSnapshot: true,
});
const cliRuntimeProviders = new Set(
listCliRuntimeModelBackendBindings().map((binding) => normalizeProviderId(binding.runtime)),
);
const catalog = await loadModelCatalogForBrowse({
cfg,
view: options.view ?? "default",
loadCatalog: ({ readOnly }) => loadModelCatalog({ config: cfg, readOnly }),
loadCatalog: ({ readOnly }) => loadModelCatalog({ config: cfg, readOnly, metadataSnapshot }),
});
const visibilityPolicy = createModelVisibilityPolicy({
cfg,
@@ -169,18 +181,27 @@ export async function buildModelsProviderData(
agentId,
...RUNTIME_MODEL_VISIBILITY_NORMALIZATION,
});
const hasAuth: (provider: string) => Promise<boolean> =
options.view === "all"
? async () => true
: createProviderAuthChecker({
cfg,
workspaceDir,
agentId,
allowPluginSyntheticAuth: false,
discoverExternalCliAuth: false,
allowPreparedRuntimeAuth: true,
});
const visibleCatalog = await resolveVisibleModelCatalog({
cfg,
catalog,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
agentId,
workspaceDir:
options.workspaceDir ??
(agentId ? resolveAgentWorkspaceDir(cfg, agentId) : undefined) ??
resolveDefaultAgentWorkspaceDir(),
workspaceDir,
view: options.view,
runtimeAuthDiscovery: false,
providerAuthChecker: hasAuth,
});
const aliasIndex = buildModelAliasIndex({
@@ -198,7 +219,7 @@ export async function buildModelsProviderData(
}
if (
restrictToProviderWildcards &&
!usesUnfilteredCatalogModels(key) &&
!usesUnfilteredCatalogModels(key, cliRuntimeProviders) &&
!visibilityPolicy.allows({ provider: key, model: m })
) {
return;
@@ -258,20 +279,11 @@ export async function buildModelsProviderData(
add(entry.provider, entry.id);
}
const hasAuth: (provider: string) => Promise<boolean> =
options.view === "all"
? async () => true
: createProviderAuthChecker({
cfg,
workspaceDir:
options.workspaceDir ??
(agentId ? resolveAgentWorkspaceDir(cfg, agentId) : undefined) ??
resolveDefaultAgentWorkspaceDir(),
agentId,
});
for (const entry of catalog) {
if (usesUnfilteredCatalogModels(entry.provider) && (await hasAuth(entry.provider))) {
if (
usesUnfilteredCatalogModels(entry.provider, cliRuntimeProviders) &&
(await hasAuth(entry.provider))
) {
add(entry.provider, entry.id);
}
}

View File

@@ -1,6 +1,9 @@
// Runs plugin hooks before outbound reply payloads are sent.
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import type { PluginHookReplyPayloadSendingContext } from "../../plugins/hook-types.js";
import type {
PluginHookReplyPayloadSendingContext,
PluginHookReplyUsageState,
} from "../../plugins/hook-types.js";
import { copyReplyPayloadMetadata } from "../reply-payload.js";
import type { ReplyPayload } from "../reply-payload.js";
import type { ReplyDispatchKind } from "./reply-dispatcher.types.js";
@@ -17,6 +20,7 @@ export async function runReplyPayloadSendingHook(params: {
channel?: string;
sessionKey?: string;
runId?: string;
usageState?: PluginHookReplyUsageState;
context: PluginHookReplyPayloadSendingContext;
}): Promise<ReplyPayload | null> {
const hookRunner = getGlobalHookRunner();
@@ -31,6 +35,7 @@ export async function runReplyPayloadSendingHook(params: {
channel: params.channel,
sessionKey: params.sessionKey,
runId: params.runId,
usageState: params.usageState,
},
params.context,
);

View File

@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearReplyUsageStateForTest,
consumeReplyUsageState,
recordReplyUsageState,
} from "./reply-usage-state.js";
afterEach(() => {
vi.useRealTimers();
clearReplyUsageStateForTest();
});
describe("reply usage state handoff", () => {
it("requires exact run correlation", () => {
const snapshot = { provider: "openai", model: "gpt-5.5" };
recordReplyUsageState("run-a", snapshot);
expect(consumeReplyUsageState()).toBeUndefined();
expect(consumeReplyUsageState("run-b")).toBeUndefined();
expect(consumeReplyUsageState("run-a")).toBe(snapshot);
});
it("ignores snapshots without a run id", () => {
recordReplyUsageState(undefined, { provider: "openai" });
expect(consumeReplyUsageState()).toBeUndefined();
});
it("expires snapshots", () => {
vi.useFakeTimers();
vi.setSystemTime(0);
recordReplyUsageState("run-a", { provider: "openai" });
vi.setSystemTime(5 * 60_000 + 1);
expect(consumeReplyUsageState("run-a")).toBeUndefined();
});
});

View File

@@ -0,0 +1,37 @@
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
const TTL_MS = 5 * 60_000;
const store = new Map<string, { snapshot: PluginHookReplyUsageState; expiresAt: number }>();
function prune(now: number): void {
for (const [key, value] of store) {
if (value.expiresAt < now) {
store.delete(key);
}
}
}
export function recordReplyUsageState(
runId: string | undefined,
snapshot: PluginHookReplyUsageState,
): void {
if (!runId) {
return;
}
const now = Date.now();
store.set(runId, { snapshot, expiresAt: now + TTL_MS });
prune(now);
}
export function consumeReplyUsageState(runId?: string): PluginHookReplyUsageState | undefined {
if (!runId) {
return undefined;
}
const value = store.get(runId);
return value && value.expiresAt >= Date.now() ? value.snapshot : undefined;
}
export function clearReplyUsageStateForTest(): void {
store.clear();
}

View File

@@ -0,0 +1,103 @@
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
import type { UsageContract } from "./translator.js";
export function buildUsageContract(
state: PluginHookReplyUsageState,
surface?: string,
): UsageContract {
const usage = state.usage ?? {};
const input = usage.input;
const output = usage.output;
const cacheRead = usage.cacheRead;
const cacheWrite = usage.cacheWrite;
const total = usage.total;
const promptTotal = (cacheRead ?? 0) + (cacheWrite ?? 0) + (input ?? 0);
const cacheHitPct =
promptTotal > 0 ? Math.round(((cacheRead ?? 0) / promptTotal) * 100) : undefined;
const last = state.lastUsage;
const lastPromptTotal = last
? (last.cacheRead ?? 0) + (last.cacheWrite ?? 0) + (last.input ?? 0)
: 0;
const lastCacheHitPct =
last && lastPromptTotal > 0
? Math.round(((last.cacheRead ?? 0) / lastPromptTotal) * 100)
: undefined;
const maxTokens = state.contextTokenBudget;
const usedTokens =
typeof state.contextUsedTokens === "number" && state.contextUsedTokens > 0
? state.contextUsedTokens
: promptTotal > 0
? promptTotal
: undefined;
const pctUsed =
maxTokens && usedTokens !== undefined ? Math.round((usedTokens / maxTokens) * 100) : undefined;
const overrideSource = state.overrideSource ?? null;
const isOverride =
typeof state.overrideSource === "string" &&
state.overrideSource !== "" &&
state.overrideSource !== "auto";
return {
schema: "openclaw.usageLine.v1",
surface: surface ?? null,
agentId: state.agentId ?? null,
chat_type: state.chatType ?? null,
model: {
id: state.model ?? null,
display_name: state.model ?? null,
provider: state.provider ?? null,
reasoning: state.reasoningEffort ?? null,
actual: state.resolvedRef ?? null,
resolved_ref: state.resolvedRef ?? null,
requested: state.requested ?? null,
is_fallback: state.fallbackUsed === true,
is_override: isOverride,
override_source: overrideSource,
auth_mode: state.authMode ?? null,
},
state: {
fast_mode: typeof state.fastMode === "boolean" ? state.fastMode : null,
compactions: typeof state.compactionCount === "number" ? state.compactionCount : null,
},
usage: {
input_tokens: input,
output_tokens: output,
cache_read_tokens: cacheRead,
cache_write_tokens: cacheWrite,
total_tokens: total,
cache_hit_pct: cacheHitPct,
last: last
? {
input_tokens: last.input,
output_tokens: last.output,
cache_read_tokens: last.cacheRead,
cache_write_tokens: last.cacheWrite,
total_tokens: last.total,
cache_hit_pct: lastCacheHitPct,
}
: undefined,
},
context: {
used_tokens: usedTokens,
max_tokens: maxTokens,
pct_used: pctUsed,
},
cost: {
turn_usd: typeof state.turnUsd === "number" ? state.turnUsd : null,
available: typeof state.turnUsd === "number",
},
timing: {
duration_ms: typeof state.durationMs === "number" ? state.durationMs : null,
},
identity: {
name: state.identity?.name ?? null,
emoji: state.identity?.emoji ?? null,
avatar: state.identity?.avatar ?? null,
},
session: { id: state.sessionId ?? null },
};
}

View File

@@ -0,0 +1,72 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { clearUsageBarTemplateCacheForTest, loadUsageBarTemplate } from "./template.js";
const tplA = { segments: [{ text: "A" }] };
const tplB = { output: { lines: [] } };
let dir: string | undefined;
afterEach(() => {
clearUsageBarTemplateCacheForTest();
if (dir) {
rmSync(dir, { recursive: true, force: true });
dir = undefined;
}
});
function tmpFile(name: string, contents: string): string {
dir = mkdtempSync(join(tmpdir(), "usage-template-"));
const path = join(dir, name);
writeFileSync(path, contents);
return path;
}
describe("loadUsageBarTemplate", () => {
it("returns an inline template object when usable", () => {
expect(loadUsageBarTemplate(tplA as Record<string, unknown>)).toBe(tplA);
});
it("returns undefined for an unusable inline object or when unset", () => {
expect(loadUsageBarTemplate({ nope: true })).toBeUndefined();
expect(loadUsageBarTemplate(undefined)).toBeUndefined();
});
it("loads and parses a template file", () => {
const path = tmpFile("t.json", JSON.stringify(tplA));
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
});
it("falls back (undefined) for invalid JSON", () => {
const path = tmpFile("bad.json", "{ not json");
expect(loadUsageBarTemplate(path)).toBeUndefined();
});
it("reloads a path after an initial miss", () => {
dir = mkdtempSync(join(tmpdir(), "usage-template-"));
const missing = join(dir, "missing.json");
expect(loadUsageBarTemplate(missing)).toBeUndefined();
writeFileSync(missing, JSON.stringify(tplB));
expect(loadUsageBarTemplate(missing)).toMatchObject(tplB);
});
it("reloads a path after invalid JSON is fixed", () => {
const path = tmpFile("bad.json", "{ not json");
expect(loadUsageBarTemplate(path)).toBeUndefined();
writeFileSync(path, JSON.stringify(tplB));
expect(loadUsageBarTemplate(path)).toMatchObject(tplB);
});
it("serves the cached template without re-reading the file", () => {
const path = tmpFile("t.json", JSON.stringify(tplA));
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
writeFileSync(path, JSON.stringify(tplB));
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
clearUsageBarTemplateCacheForTest();
expect(loadUsageBarTemplate(path)).toMatchObject(tplB);
});
});

View File

@@ -0,0 +1,86 @@
import { type FSWatcher, readFileSync, watch } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, resolve } from "node:path";
import type { UsageBarTemplate } from "./translator.js";
export type UsageTemplateConfig = string | Record<string, unknown> | undefined;
type CacheEntry = { template: UsageBarTemplate | undefined; watcher?: FSWatcher };
const fileCache = new Map<string, CacheEntry>();
function expandPath(p: string): string {
if (p === "~") {
return homedir();
}
if (p.startsWith("~/")) {
return resolve(homedir(), p.slice(2));
}
return isAbsolute(p) ? p : resolve(p);
}
function isUsableTemplate(value: unknown): value is UsageBarTemplate {
if (typeof value !== "object" || value === null) {
return false;
}
const obj = value as Record<string, unknown>;
const hasOutput = typeof obj.output === "object" && obj.output !== null;
return hasOutput || Array.isArray(obj.segments);
}
function readTemplateFile(path: string): UsageBarTemplate | undefined {
let raw: string;
try {
raw = readFileSync(path, "utf8");
} catch {
return undefined;
}
try {
const parsed: unknown = JSON.parse(raw);
return isUsableTemplate(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}
function cacheTemplateFile(path: string): UsageBarTemplate | undefined {
const entry: CacheEntry = { template: readTemplateFile(path) };
if (entry.template) {
try {
const watcher = watch(path, { persistent: false }, () => {
entry.template = readTemplateFile(path);
});
watcher.on("error", () => {
watcher.close();
});
entry.watcher = watcher;
} catch {
// Cache remains valid without live refresh.
}
}
fileCache.set(path, entry);
return entry.template;
}
export function loadUsageBarTemplate(
configured: UsageTemplateConfig,
): UsageBarTemplate | undefined {
if (!configured) {
return undefined;
}
if (typeof configured === "object") {
return isUsableTemplate(configured) ? configured : undefined;
}
const path = expandPath(configured);
const cached = fileCache.get(path);
if (cached) {
return cached.template ?? (cached.watcher ? undefined : cacheTemplateFile(path));
}
return cacheTemplateFile(path);
}
export function clearUsageBarTemplateCacheForTest(): void {
for (const entry of fileCache.values()) {
entry.watcher?.close();
}
fileCache.clear();
}

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import { buildUsageContract } from "./contract.js";
import { renderUsageBar, type UsageBarTemplate } from "./translator.js";
const SCALES = {
braille: "⠐⡀⡄⡆⡇⣇⣧⣷⣿",
moon: "🌑🌘🌗🌖🌕",
weather: ["🥶", "☁️", "🌥", "⛅️", "🌤", "☀️"],
plants: ["🪾", "🍂", "🌱", "☘️", "🍀", "🌿"],
};
function tpl(pieces: unknown[]): UsageBarTemplate {
return {
scales: SCALES,
aliases: { models: { "claude-opus-4-6": "opus46" }, reasoning: { medium: "med" } },
output: { sep: "", surfaces: { discord: pieces } },
};
}
function render(pieces: unknown[], contract: Record<string, unknown>): string {
return renderUsageBar(tpl(pieces), { surface: "discord", ...contract });
}
describe("usage-bar verbs", () => {
it("num — compact counts", () => {
expect(render([{ text: "{usage.input_tokens|num}" }], { usage: { input_tokens: 3000 } })).toBe(
"3.0k",
);
expect(render([{ text: "{x|num}" }], { x: 272000 })).toBe("272k");
expect(render([{ text: "{x|num}" }], { x: 128 })).toBe("128");
});
it("fixed — fixed-decimal precision", () => {
expect(render([{ text: "{cost|fixed:4}" }], { cost: 0.03771985 })).toBe("0.0377");
expect(render([{ text: "{cost|fixed}" }], { cost: 1.5 })).toBe("1.50");
expect(render([{ text: "{cost|fixed:0}" }], { cost: 2.7 })).toBe("3");
expect(render([{ text: "{cost|fixed:4}" }], { cost: "nope" })).toBe("");
});
it("dur — seconds to reset", () => {
expect(render([{ text: "{x|dur}" }], { x: 14820 })).toBe("4h07m");
expect(render([{ text: "{x|dur}" }], { x: 449280 })).toBe("5.2d");
expect(render([{ text: "{x|dur}" }], { x: 1980 })).toBe("33m");
});
it("pct and inv", () => {
expect(render([{ text: "{x|pct}" }], { x: 96 })).toBe("96%");
expect(render([{ text: "{x|inv|pct}" }], { x: 75 })).toBe("25%");
});
it("meter — multi-cell braille bar", () => {
expect(render([{ text: "[{x|meter:5:braille}]" }], { x: 75 })).toBe("[⣿⣿⣿⣧⠐]");
expect(render([{ text: "[{x|meter:5:braille}]" }], { x: 0 })).toBe("[⠐⠐⠐⠐⠐]");
expect(render([{ text: "[{x|meter:5:braille}]" }], { x: 100 })).toBe("[⣿⣿⣿⣿⣿]");
});
it("meter:1 — single glyph, codepoint-correct for astral scales", () => {
expect(render([{ text: "{x|meter:1:moon}" }], { x: 0 })).toBe("🌑");
expect(render([{ text: "{x|meter:1:moon}" }], { x: 50 })).toBe("🌗");
expect(render([{ text: "{x|meter:1:moon}" }], { x: 100 })).toBe("🌕");
});
it("alias — listed shortens, unlisted echoes through", () => {
expect(render([{ text: "{m|alias:models}" }], { m: "claude-opus-4-6" })).toBe("opus46");
expect(render([{ text: "{m|alias:models}" }], { m: "some-new-model" })).toBe("some-new-model");
});
it("fallback when path is missing/empty", () => {
expect(render([{ text: "{identity.emoji|🤖} hi" }], {})).toBe("🤖 hi");
expect(render([{ text: "{identity.emoji|🤖} hi" }], { identity: { emoji: "🩺" } })).toBe(
"🩺 hi",
);
});
});
describe("usage-bar segment forms", () => {
it("when drops on null/false/empty, keeps on 0", () => {
const seg = [{ when: "u.cache_hit_pct", text: "🗄 {u.cache_hit_pct|pct}" }];
expect(render(seg, { u: {} })).toBe("");
expect(render(seg, { u: { cache_hit_pct: 0 } })).toBe("🗄 0%");
});
it("map resolves enum/bool, drops on no match", () => {
const seg = [{ map: "state.fast_mode", cases: { true: "⚡", false: "🐌" } }];
expect(render(seg, { state: { fast_mode: true } })).toBe("⚡");
expect(render(seg, { state: { fast_mode: false } })).toBe("🐌");
expect(render(seg, { state: {} })).toBe("");
});
it("each with item_scales picks a scale per window by position", () => {
const seg = [
{
text: "W",
each: "windows",
item: "{pct_left|meter:1:*}{resets_in_s|dur}",
item_scales: ["weather", "plants"],
},
];
const out = render(seg, {
windows: [
{ pct_left: 92, resets_in_s: 17100 },
{ pct_left: 70, resets_in_s: 570240 },
],
});
expect(out).toBe("W ☀4h45m 🍀6.6d");
});
it("each drops the whole segment when the array is empty", () => {
expect(render([{ text: "W", each: "windows", item: "{x}" }], {})).toBe("");
});
});
describe("usage-bar end-to-end with buildUsageContract", () => {
it("renders a full footer from a reply usage snapshot", () => {
const contract = buildUsageContract(
{
provider: "openai",
model: "claude-opus-4-6",
reasoningEffort: "medium",
fastMode: false,
fallbackUsed: false,
contextTokenBudget: 272000,
contextUsedTokens: 204000,
usage: { input: 204000, output: 15, cacheRead: 0, cacheWrite: 0, total: 204015 },
turnUsd: 0.03771985,
},
"discord",
);
const pieces = [
{ text: "{model.display_name|alias:models}" },
{ map: "model.is_fallback", cases: { true: "🔄" } },
{ text: " | " },
{ when: "model.reasoning", text: "{model.reasoning|alias:reasoning}" },
{ map: "state.fast_mode", cases: { true: "⚡", false: "🐌" } },
{ text: " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}" },
{ text: " | ${cost.turn_usd|fixed:4}" },
];
expect(renderUsageBar(tpl(pieces), contract)).toBe("opus46 | med🐌 | 📚 [⣿⣿⣿⣧⠐]272k | $0.0377");
});
});

View File

@@ -0,0 +1,288 @@
export type UsageBarTemplate = Record<string, unknown>;
export type UsageContract = Record<string, unknown>;
type Vocab = Record<string, unknown>;
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
function toGlyphs(scale: unknown): string[] {
if (Array.isArray(scale)) {
return scale.filter((g): g is string => typeof g === "string");
}
if (typeof scale === "string") {
return Array.from(scale);
}
return [];
}
function num(value: unknown): string {
if (value === null || value === undefined || value === "") {
return "";
}
const n = Number(value);
if (!Number.isFinite(n)) {
return "";
}
if (Math.abs(n) >= 1000) {
const v = n / 1000;
return Math.abs(v) < 10 ? `${v.toFixed(1)}k` : `${Math.round(v)}k`;
}
return String(Math.trunc(n));
}
function fixed(value: unknown, digits: number): string {
if (value === null || value === undefined || value === "") {
return "";
}
const n = Number(value);
if (!Number.isFinite(n)) {
return "";
}
return n.toFixed(Math.max(0, digits));
}
function dur(value: unknown): string {
if (value === null || value === undefined || value === "") {
return "";
}
const raw = Number(value);
if (!Number.isFinite(raw)) {
return "";
}
const s = Math.max(0, Math.trunc(raw));
if (s >= 86400) {
return `${(s / 86400).toFixed(1)}d`;
}
if (s >= 3600) {
const m = Math.floor((s % 3600) / 60);
return `${Math.floor(s / 3600)}h${String(m).padStart(2, "0")}m`;
}
return `${Math.floor(s / 60)}m`;
}
function pct(value: unknown): string {
if (value === null || value === undefined || value === "") {
return "";
}
const n = Number(value);
return Number.isFinite(n) ? `${Math.round(n)}%` : "";
}
function inv(value: unknown): unknown {
if (value === null || value === undefined || value === "") {
return value;
}
const n = Number(value);
if (!Number.isFinite(n)) {
return value;
}
return 100 - Math.max(0, Math.min(100, n));
}
function norm(value: unknown): number {
const n = Number(value);
if (value === null || value === undefined || !Number.isFinite(n)) {
return 0;
}
return Math.max(0, Math.min(100, n)) / 100;
}
function meter(value: unknown, width: number, scale: unknown): string {
const glyphs = toGlyphs(scale);
if (glyphs.length < 2 || width < 1) {
return "";
}
const empty = glyphs[0];
const full = glyphs[glyphs.length - 1];
const total = norm(value) * width;
const fullc = Math.trunc(total);
const cells: string[] = [];
for (let i = 0; i < Math.min(fullc, width); i++) {
cells.push(full);
}
if (cells.length < width) {
cells.push(glyphs[Math.round((total - fullc) * (glyphs.length - 1))]);
}
while (cells.length < width) {
cells.push(empty);
}
return cells.slice(0, width).join("");
}
const VERB_NAMES = new Set(["num", "fixed", "dur", "pct", "inv", "alias", "meter"]);
function applyVerb(name: string, args: string[], value: unknown, vocab: Vocab): unknown {
switch (name) {
case "num":
return num(value);
case "fixed": {
const digits = args[0] ? Number.parseInt(args[0], 10) || 0 : 2;
return fixed(value, digits);
}
case "dur":
return dur(value);
case "pct":
return pct(value);
case "inv":
return inv(value);
case "alias": {
const aliases = isObject(vocab["_aliases"]) ? vocab["_aliases"] : {};
const table =
args[0] && isObject(aliases[args[0]]) ? (aliases[args[0]] as Record<string, unknown>) : {};
const key = String(value);
if (key in table) {
return table[key];
}
const lower = key.toLowerCase();
return lower in table ? table[lower] : value;
}
case "meter": {
const width = args[0] ? Number.parseInt(args[0], 10) || 5 : 5;
const scale = args.length > 1 ? vocab[args[1]] : undefined;
return meter(value, width, scale);
}
default:
return String(value);
}
}
function getPath(ctx: unknown, path: string): unknown {
let cur: unknown = ctx;
for (const part of path.split(".")) {
if (!isObject(cur)) {
return undefined;
}
cur = cur[part];
if (cur === null || cur === undefined) {
return undefined;
}
}
return cur;
}
const TOKEN = /\{([^}]+)\}/g;
function interp(text: string, ctx: unknown, vocab: Vocab): string {
return text.replace(TOKEN, (_match, body: string) => {
const parts = body.split("|");
let val = getPath(ctx, (parts[0] ?? "").trim());
const ops: Array<{ name: string; args: string[] }> = [];
let fallback: string | undefined;
for (const segRaw of parts.slice(1)) {
const seg = segRaw.trim();
const name = seg.split(":")[0];
if (VERB_NAMES.has(name)) {
ops.push({ name, args: seg.split(":").slice(1) });
} else {
fallback = seg;
}
}
if (val === null || val === undefined || val === "") {
return fallback ?? "";
}
for (const op of ops) {
val = applyVerb(op.name, op.args, val, vocab);
}
return String(val);
});
}
type Segment = Record<string, unknown>;
function renderSegment(seg: Segment, ctx: unknown, vocab: Vocab): string | null {
if ("when" in seg) {
const v = getPath(ctx, String(seg.when));
if (v === null || v === undefined || v === false || v === "") {
return null;
}
}
if ("map" in seg) {
const v = getPath(ctx, String(seg.map));
const key = typeof v === "boolean" ? String(v) : String(v);
const cases = isObject(seg.cases) ? seg.cases : {};
const hit = key in cases ? cases[key] : cases["_default"];
return typeof hit === "string" ? hit : null;
}
if ("each" in seg) {
const arr = getPath(ctx, String(seg.each));
const items = Array.isArray(arr) ? arr : [];
const itemTpl = typeof seg.item === "string" ? seg.item : "";
const names = Array.isArray(seg.item_scales) ? (seg.item_scales as string[]) : undefined;
const parts: string[] = [];
items.forEach((el, i) => {
let iv = vocab;
if (names && names.length > 0) {
iv = { ...vocab, "*": vocab[names[Math.min(i, names.length - 1)]] };
}
const r = interp(itemTpl, el, iv);
if (r) {
parts.push(r);
}
});
const join = typeof seg.join === "string" ? seg.join : " ";
const body = parts.join(join);
if (!body) {
return null;
}
const prefix = typeof seg.text === "string" ? seg.text : "";
return prefix ? `${prefix} ${body}` : body;
}
if ("text" in seg) {
return interp(String(seg.text), ctx, vocab) || null;
}
return null;
}
function resolveLayout(
template: UsageBarTemplate,
surface: unknown,
): { sep: string; pieces: Segment[] } {
const output = template.output;
if (isObject(output)) {
const surfaces = isObject(output.surfaces) ? output.surfaces : {};
let pieces = typeof surface === "string" ? surfaces[surface] : undefined;
if (pieces === undefined) {
pieces = output.default;
}
const sep = typeof output.sep === "string" ? output.sep : "";
return { sep, pieces: Array.isArray(pieces) ? (pieces as Segment[]) : [] };
}
const ov =
typeof surface === "string" &&
isObject(template.surfaces) &&
isObject(template.surfaces[surface])
? template.surfaces[surface]
: {};
const sep =
typeof ov.sep === "string" ? ov.sep : typeof template.sep === "string" ? template.sep : " ";
const segments = Array.isArray(ov.segments)
? ov.segments
: Array.isArray(template.segments)
? template.segments
: [];
return { sep, pieces: segments as Segment[] };
}
export function renderUsageBar(template: UsageBarTemplate, contract: UsageContract): string {
try {
const { sep, pieces } = resolveLayout(template, contract.surface);
const vocab: Vocab = {
...(isObject(template.ramps) ? template.ramps : {}),
...(isObject(template.series) ? template.series : {}),
...(isObject(template.scales) ? template.scales : {}),
};
vocab["_aliases"] = isObject(template.aliases) ? template.aliases : {};
const out: string[] = [];
for (const piece of pieces) {
if (isObject(piece)) {
const r = renderSegment(piece, contract, vocab);
if (r) {
out.push(r);
}
}
}
return out.join(sep);
} catch {
return "";
}
}

View File

@@ -5,7 +5,7 @@
*/
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import type { TSchema } from "typebox";
import { Type, type TSchema } from "typebox";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { defaultRuntime } from "../../runtime.js";
@@ -361,9 +361,14 @@ function mergeToolSchemaProperties(
return;
}
for (const [name, schema] of Object.entries(source)) {
if (!(name in target)) {
target[name] = schema;
if (name in target) {
continue;
}
// Message-tool params dispatch on `action`; no contributed property may be
// object-level required. Type.Object treats schemas missing typebox's
// non-enumerable `~optional` marker (plain JSON or cloned/serialized plugin
// schemas) as required, which fails validation for every message call.
target[name] = Type.IsOptional(schema) ? schema : Type.Optional(schema);
}
}

View File

@@ -194,6 +194,48 @@ describe("message action capability checks", () => {
).toHaveProperty("components");
});
it("keeps contributed schema properties optional so only action stays required", () => {
const contributingPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "demo-contrib",
label: "Demo Contrib",
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: () => ["default"],
},
}),
actions: {
describeMessageTool: () => ({
actions: ["send"],
schema: {
properties: {
// Non-optional TypeBox schema: plugin forgot Type.Optional.
components: Type.Array(Type.String()),
// Cloning strips typebox's non-enumerable `~optional` marker;
// mirrors serialized/external plugin contributions.
chatRef: structuredClone(Type.Optional(Type.String())),
media: Type.Optional(Type.String()),
},
},
}),
},
};
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "demo-contrib", source: "test", plugin: contributingPlugin },
]),
);
const properties = resolveChannelMessageToolSchemaProperties({
cfg: {} as OpenClawConfig,
channel: "demo-contrib",
});
// Regression: required leakage made every message tool call fail validation
// with "must have required properties chatRef, media, ...".
const toolSchema = Type.Object({ action: Type.String(), ...properties });
expect(toolSchema.required).toEqual(["action"]);
});
it("filters only actions that depend on current-channel-only schema", () => {
const scopedSchemaPlugin: ChannelPlugin = {
...createChannelTestPluginBase({

View File

@@ -1,6 +1,7 @@
// CLI utility tests cover shared command helpers, option parsing, and output formatting.
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
import { runCommandWithRuntime } from "./cli-utils.js";
import { registerDnsCli } from "./dns-cli.js";
import { parseByteSize } from "./parse-bytes.js";
import { parseDurationMs } from "./parse-duration.js";
@@ -33,6 +34,33 @@ describe("waitForever", () => {
});
});
describe("runCommandWithRuntime", () => {
it("surfaces cause chains and error codes through the default runtime", async () => {
const messages: string[] = [];
const exits: number[] = [];
const cause = Object.assign(new Error("invalid onRequestStart method"), {
code: "UND_ERR_INVALID_ARG",
});
const fetchError = Object.assign(new TypeError("fetch failed"), { cause });
await runCommandWithRuntime(
{
error: (message) => messages.push(message),
exit: (code) => exits.push(code),
},
async () => {
throw fetchError;
},
);
expect(messages).toHaveLength(1);
expect(messages[0]).toContain("TypeError: fetch failed");
expect(messages[0]).toContain("invalid onRequestStart method");
expect(messages[0]).toContain("UND_ERR_INVALID_ARG");
expect(exits).toEqual([1]);
});
});
describe("shouldSkipRespawnForArgv", () => {
it.each([
{ argv: ["node", "openclaw", "--help"] },

View File

@@ -32,6 +32,13 @@ export async function withManager<T>(params: {
}
}
function formatCommandRuntimeError(err: unknown): string {
if (err instanceof Error) {
return formatErrorMessage(new Error(String(err), { cause: err.cause }));
}
return formatErrorMessage(err);
}
export async function runCommandWithRuntime(
runtime: { error: (message: string) => void; exit: (code: number) => void },
action: () => Promise<void>,
@@ -44,7 +51,7 @@ export async function runCommandWithRuntime(
onError(err);
return;
}
runtime.error(String(err));
runtime.error(formatCommandRuntimeError(err));
runtime.exit(1);
}
}

View File

@@ -19,7 +19,7 @@ vi.mock("../../plugins/provider-runtime.js", () => ({
normalizeProviderResolvedModelWithPlugin: mocks.normalizeProviderResolvedModelWithPlugin,
}));
import { appendProviderCatalogRows } from "./list.rows.js";
import { appendConfiguredProviderRows, appendProviderCatalogRows } from "./list.rows.js";
const authIndex = {
hasProviderAuth: (provider: string) => provider === "codex",
@@ -79,6 +79,7 @@ describe("appendProviderCatalogRows", () => {
models: { providers: {} },
},
});
expect(mocks.normalizeProviderResolvedModelWithPlugin).not.toHaveBeenCalled();
const row = requireOnlyRow(rows);
expect(row.key).toBe("codex/gpt-5.5");
expect(row.available).toBe(true);
@@ -189,3 +190,53 @@ describe("appendProviderCatalogRows", () => {
expect(row.tags).toEqual(["configured"]);
});
});
describe("appendConfiguredProviderRows", () => {
it("keeps provider normalization for configured provider models", async () => {
mocks.normalizeProviderResolvedModelWithPlugin.mockReturnValueOnce({
provider: "anthropic",
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
input: ["text", "image"],
contextWindow: 200_000,
} as never);
const rows: ModelRow[] = [];
await appendConfiguredProviderRows({
rows,
seenKeys: new Set(),
context: {
cfg: {
models: {
providers: {
anthropic: {
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
models: [
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8192,
},
],
},
},
},
},
agentDir: "/tmp/openclaw-agent",
authIndex,
configuredByKey: new Map(),
discoveredKeys: new Set(),
filter: { provider: "anthropic", local: false },
skipRuntimeModelSuppression: true,
},
});
expect(mocks.normalizeProviderResolvedModelWithPlugin).toHaveBeenCalledOnce();
expect(requireOnlyRow(rows).input).toBe("text+image");
});
});

View File

@@ -145,6 +145,7 @@ function normalizeListRowWithProviderPlugin(params: {
provider: params.model.provider,
config: params.context.cfg,
workspaceDir: params.context.workspaceDir,
pluginMetadataSnapshot: params.context.metadataSnapshot,
context: {
config: params.context.cfg,
agentDir: params.context.agentDir,
@@ -177,6 +178,7 @@ async function appendVisibleRow(params: {
seenKeys?: Set<string>;
allowProviderAvailabilityFallback?: boolean;
skipSuppression?: boolean;
normalizeWithProviderPlugin?: boolean;
}): Promise<boolean> {
if (params.seenKeys?.has(params.key)) {
return false;
@@ -184,21 +186,18 @@ async function appendVisibleRow(params: {
if (!matchesRowFilter(params.context, params.model)) {
return false;
}
const normalizedModel = normalizeListRowWithProviderPlugin({
model: params.model,
context: params.context,
});
// Normalize provider-owned runtime model ids before suppression/filtering so
// list output matches the model ids users can actually select.
if (
!params.skipSuppression &&
shouldSuppressListModel({ model: normalizedModel, context: params.context })
) {
const model = params.normalizeWithProviderPlugin
? normalizeListRowWithProviderPlugin({
model: params.model,
context: params.context,
})
: params.model;
if (!params.skipSuppression && shouldSuppressListModel({ model, context: params.context })) {
return false;
}
params.rows.push(
await buildRow({
model: normalizedModel,
model,
key: params.key,
context: params.context,
allowProviderAvailabilityFallback: params.allowProviderAvailabilityFallback,
@@ -375,6 +374,7 @@ export async function appendConfiguredProviderRows(params: {
context: params.context,
seenKeys: params.seenKeys,
allowProviderAvailabilityFallback: !params.context.discoveredKeys.has(key),
normalizeWithProviderPlugin: true,
});
}
}

View File

@@ -1873,6 +1873,8 @@ export const FIELD_HELP: Record<string, string> = {
'Controls visible source replies across direct, group, and channel conversations. "message_tool" requires message(action=send) for visible output and keeps normal final text private. "automatic" posts normal replies as before.',
"messages.responsePrefix":
"Prefix text prepended to outbound assistant replies before sending to channels. Use for lightweight branding/context tags and avoid long prefixes that reduce content density.",
"messages.usageTemplate":
"Custom /usage full footer template, either an inline object or a JSON file path. Invalid or unavailable templates fall back to the built-in usage line.",
"messages.groupChat":
"Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.",
"messages.groupChat.mentionPatterns":

View File

@@ -967,6 +967,7 @@ export const FIELD_LABELS: Record<string, string> = {
"messages.messagePrefix": "Inbound Message Prefix",
"messages.visibleReplies": "Visible Replies",
"messages.responsePrefix": "Outbound Response Prefix",
"messages.usageTemplate": "Usage Footer Template",
"messages.groupChat": "Group Chat Rules",
"messages.groupChat.mentionPatterns": "Group Mention Patterns",
"messages.groupChat.historyLimit": "Group History Limit",

View File

@@ -139,6 +139,8 @@ export type MessagesConfig = {
* Default: none
*/
responsePrefix?: string;
/** Custom `/usage full` footer template, inline or JSON file path. */
usageTemplate?: string | Record<string, unknown>;
groupChat?: GroupChatConfig;
queue?: QueueConfig;
/** Debounce rapid inbound messages per sender (global + per-channel overrides). */

View File

@@ -158,6 +158,7 @@ export const MessagesSchema = z
messagePrefix: z.string().optional(),
visibleReplies: VisibleRepliesSchema.optional(),
responsePrefix: z.string().optional(),
usageTemplate: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
groupChat: GroupChatSchema,
queue: QueueSchema,
inbound: InboundDebounceSchema,

View File

@@ -278,21 +278,48 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
});
it("supports base64 encoding and agent-scoped auth/config resolution", async () => {
const res = await postEmbeddings(
{
model: "openclaw/beta",
input: "hello",
encoding_format: "base64",
},
{ "x-openclaw-agent-id": "beta" },
);
expect(res.status).toBe(200);
const json = (await res.json()) as { data?: Array<{ embedding?: string }> };
expect(typeof json.data?.[0]?.embedding).toBe("string");
expect(createEmbeddingProviderMock).toHaveBeenCalled();
const lastCall = latestCreateEmbeddingProviderOptions();
expect(typeof lastCall.model).toBe("string");
expect(lastCall.agentDir).toBe(resolveAgentDir({}, "beta"));
try {
testState.agentsConfig = { list: [{ id: "main" }, { id: "beta" }] };
resetConfigRuntimeState();
const res = await postEmbeddings(
{
model: "openclaw/beta",
input: "hello",
encoding_format: "base64",
},
{ "x-openclaw-agent-id": "beta" },
);
expect(res.status).toBe(200);
const json = (await res.json()) as { data?: Array<{ embedding?: string }> };
expect(typeof json.data?.[0]?.embedding).toBe("string");
expect(createEmbeddingProviderMock).toHaveBeenCalled();
const lastCall = latestCreateEmbeddingProviderOptions();
expect(typeof lastCall.model).toBe("string");
expect(lastCall.agentDir).toBe(resolveAgentDir({}, "beta"));
} finally {
testState.agentsConfig = undefined;
resetConfigRuntimeState();
}
});
it("rejects explicit unknown agent ids", async () => {
try {
testState.agentsConfig = { list: [{ id: "main" }, { id: "beta" }] };
resetConfigRuntimeState();
const header = await postEmbeddings(
{ model: "openclaw/default", input: "hello" },
{ "x-openclaw-agent-id": "missing-agent" },
);
await expectInvalidEmbeddingRequest(header, "Unknown agent 'missing-agent'.");
const model = await postEmbeddings({ model: "openclaw/missing-agent", input: "hello" });
await expectInvalidEmbeddingRequest(model, "Unknown agent 'missing-agent'.");
} finally {
testState.agentsConfig = undefined;
resetConfigRuntimeState();
}
});
it("rejects invalid input shapes", async () => {
@@ -429,6 +456,38 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
);
});
it("rejects x-openclaw-model for trusted write-only callers", async () => {
const port = await getFreePort();
const server = await startOpenAiCompatGatewayServer({
startGatewayServer,
port,
auth: { mode: "none" },
openAiChatCompletionsEnabled: true,
});
try {
createEmbeddingProviderMock.mockClear();
const res = await fetch(`http://127.0.0.1:${port}/v1/embeddings`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-openclaw-scopes": "operator.write",
"x-openclaw-model": "openai/text-embedding-3-small",
},
body: JSON.stringify({
model: "openclaw/default",
input: "hello",
}),
});
expect(res.status).toBe(403);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
expect(json.error?.type).toBe("forbidden");
expect(json.error?.message).toBe("missing scope: operator.admin");
expect(createEmbeddingProviderMock).not.toHaveBeenCalled();
} finally {
await server.close({ reason: "embeddings model override auth test done" });
}
});
it("rejects oversized batches", async () => {
const res = await postEmbeddings({
model: "openclaw/default",

View File

@@ -24,11 +24,13 @@ import type {
} from "../plugins/memory-embedding-providers.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import { sendJson } from "./http-common.js";
import { sendJson, sendMissingScopeForbidden } from "./http-common.js";
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
import {
OPENCLAW_MODEL_ID,
authorizeOpenAiCompatibleHttpModelOverride,
getHeader,
isUnknownGatewayAgentError,
resolveAgentIdForRequest,
resolveAgentIdFromModel,
resolveOpenAiCompatibleHttpOperatorScopes,
@@ -252,6 +254,11 @@ export async function handleOpenAiEmbeddingsHttpRequest(
if (!handled) {
return true;
}
const modelOverrideAuth = authorizeOpenAiCompatibleHttpModelOverride(req, handled.requestAuth);
if (!modelOverrideAuth.allowed) {
sendMissingScopeForbidden(res, modelOverrideAuth.missingScope);
return true;
}
const payload = coerceRequest(handled.body);
const requestModel = normalizeOptionalString(payload.model) ?? "";
@@ -291,7 +298,18 @@ export async function handleOpenAiEmbeddingsHttpRequest(
return true;
}
const agentId = resolveAgentIdForRequest({ req, model: requestModel });
let agentId: string;
try {
agentId = resolveAgentIdForRequest({ req, model: requestModel });
} catch (err) {
if (isUnknownGatewayAgentError(err)) {
sendJson(res, 400, {
error: { message: err.message, type: "invalid_request_error" },
});
return true;
}
throw err;
}
const agentDir = resolveAgentDir(cfg, agentId);
const memorySearch = resolveMemorySearchConfig(cfg, agentId);
const configuredProvider = memorySearch?.provider ?? "openai";

View File

@@ -260,3 +260,14 @@ export function resolveOpenAiCompatibleHttpSenderIsOwner(
}
return resolveHttpSenderIsOwner(req, requestAuth);
}
export function authorizeOpenAiCompatibleHttpModelOverride(
req: IncomingMessage,
requestAuth: AuthorizedGatewayHttpRequest,
): { allowed: true } | { allowed: false; missingScope: typeof ADMIN_SCOPE } {
const requestedModelOverride = normalizeOptionalString(getHeader(req, "x-openclaw-model"));
if (!requestedModelOverride || resolveOpenAiCompatibleHttpSenderIsOwner(req, requestAuth)) {
return { allowed: true };
}
return { allowed: false, missingScope: ADMIN_SCOPE };
}

View File

@@ -4,6 +4,7 @@
import type { IncomingMessage } from "node:http";
import { describe, expect, it } from "vitest";
import {
authorizeOpenAiCompatibleHttpModelOverride,
resolveOpenAiCompatibleHttpOperatorScopes,
resolveOpenAiCompatibleHttpSenderIsOwner,
resolveGatewayRequestContext,
@@ -54,6 +55,35 @@ describe("resolveGatewayRequestContext", () => {
expect(result.sessionKey).toContain("openresponses-user:alice");
});
it("does not build session state for explicit unknown agent ids", () => {
expect(() =>
resolveGatewayRequestContext({
req: createReq({ "x-openclaw-agent-id": "missing-agent" }),
model: "openclaw",
sessionPrefix: "openai",
defaultMessageChannel: "webchat",
}),
).toThrow(/Unknown agent/);
expect(() =>
resolveGatewayRequestContext({
req: createReq(),
model: "openclaw/missing-agent",
sessionPrefix: "openai",
defaultMessageChannel: "webchat",
}),
).toThrow(/Unknown agent/);
expect(() =>
resolveGatewayRequestContext({
req: createReq({ "x-openclaw-agent-id": "!!!" }),
model: "openclaw",
sessionPrefix: "openai",
defaultMessageChannel: "webchat",
}),
).toThrow("Unknown agent '!!!'.");
});
});
describe("resolveTrustedHttpOperatorScopes", () => {
@@ -188,3 +218,38 @@ describe("resolveOpenAiCompatibleHttpSenderIsOwner", () => {
).toBe(true);
});
});
describe("authorizeOpenAiCompatibleHttpModelOverride", () => {
it("allows shared-secret bearer callers to use x-openclaw-model", () => {
expect(
authorizeOpenAiCompatibleHttpModelOverride(
createReq({ authorization: "Bearer secret", "x-openclaw-model": "openai/gpt-5.4" }),
{ authMethod: "token", trustDeclaredOperatorScopes: false },
),
).toEqual({ allowed: true });
});
it("allows trusted admin callers to use x-openclaw-model", () => {
expect(
authorizeOpenAiCompatibleHttpModelOverride(
createReq({
"x-openclaw-scopes": "operator.admin, operator.write",
"x-openclaw-model": "openai/gpt-5.4",
}),
{ authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true },
),
).toEqual({ allowed: true });
});
it("rejects trusted write-only callers that try to use x-openclaw-model", () => {
expect(
authorizeOpenAiCompatibleHttpModelOverride(
createReq({
"x-openclaw-scopes": "operator.write",
"x-openclaw-model": "openai/gpt-5.4",
}),
{ authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true },
),
).toEqual({ allowed: false, missingScope: "operator.admin" });
});
});

View File

@@ -6,17 +6,22 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { modelKey, parseModelRef, resolveDefaultModelForAgent } from "../agents/model-selection.js";
import { createModelVisibilityPolicy } from "../agents/model-visibility-policy.js";
import { getRuntimeConfig } from "../config/io.js";
import { loadManifestMetadataSnapshot } from "../plugins/manifest-contract-eligibility.js";
import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js";
import {
buildAgentMainSessionKey,
isValidAgentId,
normalizeAgentId,
} from "../routing/session-key.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import { getHeader } from "./http-auth-utils.js";
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
export {
authorizeOpenAiCompatibleHttpModelOverride,
authorizeGatewayHttpRequestOrReply,
authorizeScopedGatewayHttpRequestOrReply,
checkGatewayHttpRequestAuth,
@@ -37,6 +42,23 @@ export const OPENCLAW_MODEL_ID = "openclaw";
/** Default OpenAI-compatible model alias that targets the default OpenClaw agent. */
export const OPENCLAW_DEFAULT_MODEL_ID = "openclaw/default";
export class UnknownGatewayAgentError extends Error {
constructor(readonly agentId: string) {
super(`Unknown agent '${agentId}'.`);
this.name = "UnknownGatewayAgentError";
}
}
export function isUnknownGatewayAgentError(err: unknown): err is UnknownGatewayAgentError {
return err instanceof UnknownGatewayAgentError;
}
function assertKnownAgentId(agentId: string, cfg = getRuntimeConfig()): void {
if (!listAgentIds(cfg).includes(agentId)) {
throw new UnknownGatewayAgentError(agentId);
}
}
function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined {
const raw =
normalizeOptionalString(getHeader(req, "x-openclaw-agent-id")) ||
@@ -45,6 +67,9 @@ function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined {
if (!raw) {
return undefined;
}
if (!isValidAgentId(raw)) {
throw new UnknownGatewayAgentError(raw);
}
return normalizeAgentId(raw);
}
@@ -139,11 +164,17 @@ export function resolveAgentIdForRequest(params: {
const cfg = getRuntimeConfig();
const fromHeader = resolveAgentIdFromHeader(params.req);
if (fromHeader) {
assertKnownAgentId(fromHeader, cfg);
return fromHeader;
}
const fromModel = resolveAgentIdFromModel(params.model, cfg);
return fromModel ?? resolveDefaultAgentId(cfg);
if (fromModel) {
assertKnownAgentId(fromModel, cfg);
return fromModel;
}
return resolveDefaultAgentId(cfg);
}
function resolveSessionKey(params: {

View File

@@ -13,6 +13,7 @@ import { subscribeEmbeddedAgentSession } from "../agents/embedded-agent-subscrib
import { FailoverError } from "../agents/failover-error.js";
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
import { resetConfigRuntimeState } from "../config/config.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { buildAssistantDeltaResult } from "./test-helpers.agent-results.js";
import {
@@ -187,6 +188,9 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
};
try {
testState.agentsConfig = { list: [{ id: "main" }, { id: "beta" }] };
resetConfigRuntimeState();
{
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "GET",
@@ -233,6 +237,33 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
matcher: /^agent:main:/,
});
{
agentCommand.mockClear();
const res = await postChatCompletions(
port,
{ model: "openclaw", messages: [{ role: "user", content: "hi" }] },
{ "x-openclaw-agent-id": "missing-agent" },
);
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
expect(json.error?.type).toBe("invalid_request_error");
expect(json.error?.message).toBe("Unknown agent 'missing-agent'.");
expect(agentCommand).toHaveBeenCalledTimes(0);
}
{
agentCommand.mockClear();
const res = await postChatCompletions(port, {
model: "openclaw/missing-agent",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
expect(json.error?.type).toBe("invalid_request_error");
expect(json.error?.message).toBe("Unknown agent 'missing-agent'.");
expect(agentCommand).toHaveBeenCalledTimes(0);
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(
@@ -287,6 +318,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
},
{
"x-openclaw-model": "openai/gpt-5.4",
"x-openclaw-scopes": "operator.admin, operator.write",
},
);
expect(res.status).toBe(200);
@@ -314,6 +346,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
},
{
"x-openclaw-model": "gpt-5.4",
"x-openclaw-scopes": "operator.admin, operator.write",
},
);
expect(res.status).toBe(200);
@@ -345,7 +378,27 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
},
{ "x-openclaw-model": "openai/" },
{ "x-openclaw-model": "openai/gpt-5.4" },
);
expect(res.status).toBe(403);
const json = (await res.json()) as { error?: { message?: string; type?: string } };
expect(json.error?.type).toBe("forbidden");
expect(json.error?.message).toBe("missing scope: operator.admin");
expect(agentCommand).toHaveBeenCalledTimes(0);
}
{
agentCommand.mockClear();
const res = await postChatCompletions(
port,
{
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
},
{
"x-openclaw-model": "openai/",
"x-openclaw-scopes": "operator.admin, operator.write",
},
);
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
@@ -1397,7 +1450,8 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
);
}
} finally {
// shared server
testState.agentsConfig = undefined;
resetConfigRuntimeState();
}
});

View File

@@ -42,9 +42,17 @@ import {
} from "./agent-prompt.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import { sendJson, setSseHeaders, watchClientDisconnect, writeDone } from "./http-common.js";
import {
sendJson,
sendMissingScopeForbidden,
setSseHeaders,
watchClientDisconnect,
writeDone,
} from "./http-common.js";
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
import {
authorizeOpenAiCompatibleHttpModelOverride,
isUnknownGatewayAgentError,
resolveGatewayRequestContext,
resolveOpenAiCompatModelOverride,
resolveOpenAiCompatibleHttpOperatorScopes,
@@ -165,7 +173,7 @@ function buildAgentCommandInput(params: {
deliver: false as const,
messageChannel: params.messageChannel,
bestEffortDeliver: false as const,
allowModelOverride: true as const,
allowModelOverride: params.modelOverride !== undefined,
abortSignal: params.abortSignal,
streamParams: params.streamParams,
};
@@ -886,6 +894,11 @@ export async function handleOpenAiHttpRequest(
if (!handled) {
return true;
}
const modelOverrideAuth = authorizeOpenAiCompatibleHttpModelOverride(req, handled.requestAuth);
if (!modelOverrideAuth.allowed) {
sendMissingScopeForbidden(res, modelOverrideAuth.missingScope);
return true;
}
const payload = coerceRequest(handled.body);
const stream = Boolean(payload.stream);
const streamIncludeUsage = stream && resolveIncludeUsageForStreaming(payload);
@@ -962,14 +975,27 @@ export async function handleOpenAiHttpRequest(
}
: undefined;
const { agentId, sessionKey, messageChannel } = resolveGatewayRequestContext({
req,
model,
user,
sessionPrefix: "openai",
defaultMessageChannel: "webchat",
useMessageChannelHeader: true,
});
let agentId: string;
let sessionKey: string;
let messageChannel: string;
try {
({ agentId, sessionKey, messageChannel } = resolveGatewayRequestContext({
req,
model,
user,
sessionPrefix: "openai",
defaultMessageChannel: "webchat",
useMessageChannelHeader: true,
}));
} catch (err) {
if (isUnknownGatewayAgentError(err)) {
sendJson(res, 400, {
error: { message: err.message, type: "invalid_request_error" },
});
return true;
}
throw err;
}
const { modelOverride, errorMessage: modelError } = await resolveOpenAiCompatModelOverride({
req,
agentId,

View File

@@ -8,6 +8,7 @@ import { createClientToolNameConflictError } from "../agents/agent-tool-definiti
import { FailoverError } from "../agents/failover-error.js";
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
import { resetConfigRuntimeState } from "../config/config.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { buildAssistantDeltaResult } from "./test-helpers.agent-results.js";
import {
@@ -15,6 +16,7 @@ import {
getFreePort,
installGatewayTestHooks,
startGatewayServerWithRetries,
testState,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
@@ -263,6 +265,9 @@ describe("OpenResponses HTTP API (e2e)", () => {
};
try {
testState.agentsConfig = { list: [{ id: "main" }, { id: "beta" }] };
resetConfigRuntimeState();
const resNonPost = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
method: "GET",
headers: { authorization: "Bearer secret" },
@@ -333,6 +338,30 @@ describe("OpenResponses HTTP API (e2e)", () => {
);
await ensureResponseConsumed(resDefaultAlias);
{
agentCommand.mockClear();
const res = await postResponses(
port,
{ model: "openclaw", input: "hi" },
{ "x-openclaw-agent-id": "missing-agent" },
);
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
expect(json.error?.type).toBe("invalid_request_error");
expect(json.error?.message).toBe("Unknown agent 'missing-agent'.");
expect(agentCommand).toHaveBeenCalledTimes(0);
}
{
agentCommand.mockClear();
const res = await postResponses(port, { model: "openclaw/missing-agent", input: "hi" });
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
expect(json.error?.type).toBe("invalid_request_error");
expect(json.error?.message).toBe("Unknown agent 'missing-agent'.");
expect(agentCommand).toHaveBeenCalledTimes(0);
}
mockAgentOnce([{ text: "hello" }]);
const resChannelHeader = await postResponses(
port,
@@ -353,7 +382,10 @@ describe("OpenResponses HTTP API (e2e)", () => {
model: "openclaw",
input: "hi",
},
{ "x-openclaw-model": "openai/gpt-5.4" },
{
"x-openclaw-model": "openai/gpt-5.4",
"x-openclaw-scopes": "operator.admin, operator.write",
},
);
expect(resModelOverride.status).toBe(200);
const optsModelOverride = firstAgentOpts();
@@ -364,7 +396,10 @@ describe("OpenResponses HTTP API (e2e)", () => {
const resInvalidOverride = await postResponses(
port,
{ model: "openclaw", input: "hi" },
{ "x-openclaw-model": "openai/" },
{
"x-openclaw-model": "openai/",
"x-openclaw-scopes": "operator.admin, operator.write",
},
);
expect(resInvalidOverride.status).toBe(400);
const invalidOverrideJson = (await resInvalidOverride.json()) as {
@@ -375,6 +410,21 @@ describe("OpenResponses HTTP API (e2e)", () => {
expect(agentCommand).toHaveBeenCalledTimes(0);
await ensureResponseConsumed(resInvalidOverride);
agentCommand.mockClear();
const resWriteOnlyOverride = await postResponses(
port,
{ model: "openclaw", input: "hi" },
{ "x-openclaw-model": "openai/gpt-5.4" },
);
expect(resWriteOnlyOverride.status).toBe(403);
const writeOnlyJson = (await resWriteOnlyOverride.json()) as {
error?: { type?: string; message?: string };
};
expect(writeOnlyJson.error?.type).toBe("forbidden");
expect(writeOnlyJson.error?.message).toBe("missing scope: operator.admin");
expect(agentCommand).toHaveBeenCalledTimes(0);
await ensureResponseConsumed(resWriteOnlyOverride);
agentCommand.mockClear();
agentCommand.mockRejectedValueOnce(createClientToolNameConflictError(["exec"]));
const resToolConflict = await postResponses(port, {
@@ -797,7 +847,8 @@ describe("OpenResponses HTTP API (e2e)", () => {
);
await ensureResponseConsumed(resNoUser);
} finally {
// shared server
testState.agentsConfig = undefined;
resetConfigRuntimeState();
}
});

View File

@@ -36,11 +36,19 @@ import { defaultRuntime } from "../runtime.js";
import { resolveAssistantStreamDeltaText } from "./agent-event-assistant-text.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import { sendJson, setSseHeaders, watchClientDisconnect, writeDone } from "./http-common.js";
import {
sendJson,
sendMissingScopeForbidden,
setSseHeaders,
watchClientDisconnect,
writeDone,
} from "./http-common.js";
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
import {
authorizeOpenAiCompatibleHttpModelOverride,
getBearerToken,
getHeader,
isUnknownGatewayAgentError,
resolveAgentIdForRequest,
resolveGatewayRequestContext,
resolveOpenAiCompatModelOverride,
@@ -425,7 +433,7 @@ async function runResponsesAgentCommand(params: {
deliver: false,
messageChannel: params.messageChannel,
bestEffortDeliver: false,
allowModelOverride: true,
allowModelOverride: params.modelOverride !== undefined,
abortSignal: params.abortSignal,
},
defaultRuntime,
@@ -462,6 +470,11 @@ export async function handleOpenResponsesHttpRequest(
if (!handled) {
return true;
}
const modelOverrideAuth = authorizeOpenAiCompatibleHttpModelOverride(req, handled.requestAuth);
if (!modelOverrideAuth.allowed) {
sendMissingScopeForbidden(res, modelOverrideAuth.missingScope);
return true;
}
// Validate request body with Zod
const parseResult = CreateResponseBodySchema.safeParse(handled.body);
if (!parseResult.success) {
@@ -477,7 +490,18 @@ export async function handleOpenResponsesHttpRequest(
const stream = Boolean(payload.stream);
const model = payload.model;
const user = payload.user;
const agentId = resolveAgentIdForRequest({ req, model });
let agentId: string;
try {
agentId = resolveAgentIdForRequest({ req, model });
} catch (err) {
if (isUnknownGatewayAgentError(err)) {
sendJson(res, 400, {
error: { message: err.message, type: "invalid_request_error" },
});
return true;
}
throw err;
}
const { modelOverride, errorMessage: modelError } = await resolveOpenAiCompatModelOverride({
req,
agentId,
@@ -624,14 +648,25 @@ export async function handleOpenResponsesHttpRequest(
});
return true;
}
const resolved = resolveGatewayRequestContext({
req,
model,
user,
sessionPrefix: "openresponses",
defaultMessageChannel: "webchat",
useMessageChannelHeader: true,
});
let resolved: ReturnType<typeof resolveGatewayRequestContext>;
try {
resolved = resolveGatewayRequestContext({
req,
model,
user,
sessionPrefix: "openresponses",
defaultMessageChannel: "webchat",
useMessageChannelHeader: true,
});
} catch (err) {
if (isUnknownGatewayAgentError(err)) {
sendJson(res, 400, {
error: { message: err.message, type: "invalid_request_error" },
});
return true;
}
throw err;
}
const responseSessionScope = createResponseSessionScope({
req,
auth: opts.auth,

View File

@@ -14,7 +14,6 @@ import {
type AuthProfileStore,
} from "../../agents/auth-profiles.js";
import { DEFAULT_PROVIDER } from "../../agents/defaults.js";
import { NON_ENV_SECRETREF_MARKER } from "../../agents/model-auth-markers.js";
import { hasRuntimeAvailableProviderAuth } from "../../agents/model-auth.js";
import {
loadModelCatalogForBrowse,
@@ -57,18 +56,6 @@ function omitRuntimeModelParams(entry: ModelCatalogEntry): ModelCatalogEntry {
return rest;
}
function modelCatalogEntryHasUnknownSecretRefAvailability(
cfg: OpenClawConfig,
entry: ModelCatalogEntry,
): boolean {
const providerId = normalizeProviderId(entry.provider);
const provider = Object.entries(cfg.models?.providers ?? {}).find(
([id]) => normalizeProviderId(id) === providerId,
)?.[1];
const apiKey = provider?.apiKey;
return apiKey === NON_ENV_SECRETREF_MARKER || (isSecretRef(apiKey) && apiKey.source !== "env");
}
function createInFlightProviderAuthChecker(
providerAuthChecker: ModelsListProviderAuthChecker,
): ModelsListProviderAuthChecker {
@@ -219,16 +206,9 @@ async function resolveModelsListEntryAvailability(
async function buildPublicModelsListEntry(params: {
entry: ModelCatalogEntry;
cfg: OpenClawConfig;
providerAuthChecker?: ModelsListProviderAuthChecker;
}): Promise<ModelsListEntry> {
const publicEntry = omitRuntimeModelParams(params.entry);
if (modelCatalogEntryHasUnknownSecretRefAvailability(params.cfg, params.entry)) {
return {
...publicEntry,
available: false,
};
}
if (!params.providerAuthChecker) {
return publicEntry;
}
@@ -253,7 +233,6 @@ async function buildPublicModelsListEntries(params: {
params.catalog.map((entry) =>
buildPublicModelsListEntry({
entry,
cfg: params.cfg,
providerAuthChecker,
}),
),

View File

@@ -565,6 +565,90 @@ describe("models.list", () => {
);
});
it("marks auth profiles available even when provider config uses non-env SecretRef markers", async () => {
for (const fixture of [
{
name: "file",
apiKey: {
source: "file",
provider: "mounted-json",
id: "/providers/vllm/apiKey",
},
},
{ name: "managed-marker", apiKey: "secretref-managed" },
] as const) {
await withOpenClawTestState(
{
layout: "state-only",
prefix: `openclaw-models-list-provider-${fixture.name}-profile-`,
agentEnv: "main",
env: {
OPENCLAW_TEST_PROFILE_API_KEY: "test-token",
VLLM_API_KEY: undefined,
},
},
async (state) => {
await state.writeAuthProfiles({
version: 1,
profiles: {
"vllm:env": {
type: "api_key",
provider: "vllm",
keyRef: {
source: "env",
provider: "default",
id: "OPENCLAW_TEST_PROFILE_API_KEY",
},
},
},
});
const cfg = {
agents: {
defaults: {
models: {
"vllm/*": {},
},
},
},
models: {
providers: {
vllm: {
apiKey: fixture.apiKey,
},
},
},
} as unknown as OpenClawConfig;
const { request, respond } = requestModelsList({
view: "all",
runtimeConfig: cfg,
loadGatewayModelCatalog: vi.fn(() =>
Promise.resolve([{ id: "llama-secure", name: "Llama Secure", provider: "vllm" }]),
),
reqId: `req-models-list-provider-${fixture.name}-profile`,
});
await request;
expect(respond).toHaveBeenCalledWith(
true,
{
models: [
{
id: "llama-secure",
name: "Llama Secure",
provider: "vllm",
available: true,
},
],
},
undefined,
);
},
);
}
});
it("preserves catalog load errors before the timeout fallback wins", async () => {
const { request, respond } = requestModelsList({
view: "configured",

View File

@@ -15,14 +15,14 @@ const REQUESTER_WRITE_HEADERS = {
"x-openclaw-scopes": "operator.write",
"x-openclaw-requester-session-key": "agent:main:main",
};
const REQUESTER_ADMIN_HEADERS = {
"x-openclaw-scopes": "operator.admin",
"x-openclaw-requester-session-key": "agent:other:main",
};
let cfg: Record<string, unknown> = {};
const authMock = vi.fn(async (): Promise<GatewayAuthResult> => ({ ok: true }));
const isLocalDirectRequestMock = vi.fn(() => true);
const loadSessionEntryMock = vi.fn();
const getLatestSubagentRunByChildSessionKeyMock = vi.fn();
const resolveSubagentControllerMock = vi.fn();
const killControlledSubagentRunMock = vi.fn();
const killSubagentRunAdminMock = vi.fn();
vi.mock("../config/config.js", () => ({
@@ -35,21 +35,14 @@ vi.mock("../config/io.js", () => ({
vi.mock("./auth.js", () => ({
authorizeHttpGatewayConnect: authMock,
isLocalDirectRequest: isLocalDirectRequestMock,
}));
vi.mock("./session-utils.js", () => ({
loadSessionEntry: loadSessionEntryMock,
}));
vi.mock("../agents/subagent-registry.js", () => ({
getLatestSubagentRunByChildSessionKey: getLatestSubagentRunByChildSessionKeyMock,
}));
vi.mock("../agents/subagent-control.js", () => ({
killControlledSubagentRun: killControlledSubagentRunMock,
killSubagentRunAdmin: killSubagentRunAdminMock,
resolveSubagentController: resolveSubagentControllerMock,
}));
const { handleSessionKillHttpRequest } = await import("./session-kill-http.js");
@@ -93,13 +86,7 @@ beforeEach(() => {
cfg = {};
authMock.mockReset();
authMock.mockResolvedValue({ ok: true, method: "token" });
isLocalDirectRequestMock.mockReset();
isLocalDirectRequestMock.mockReturnValue(true);
loadSessionEntryMock.mockReset();
getLatestSubagentRunByChildSessionKeyMock.mockReset();
resolveSubagentControllerMock.mockReset();
resolveSubagentControllerMock.mockReturnValue({ controllerSessionKey: "agent:main:main" });
killControlledSubagentRunMock.mockReset();
killSubagentRunAdminMock.mockReset();
});
@@ -134,12 +121,6 @@ function mockWorkerSession() {
});
}
function allowRemoteRequesterKill() {
isLocalDirectRequestMock.mockReturnValue(false);
allowTrustedProxyAuth();
mockWorkerSession();
}
async function expectForbiddenMissingScope(response: Response, message: string) {
expect(response.status).toBe(403);
expectErrorResponse(await response.json(), {
@@ -148,11 +129,6 @@ async function expectForbiddenMissingScope(response: Response, message: string)
});
}
async function expectRequesterKillResponse(response: Response, killed: boolean) {
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ ok: true, killed });
}
function expectErrorResponse(body: unknown, expected: { type: string; message?: string }) {
const response = body as {
ok?: unknown;
@@ -210,7 +186,6 @@ describe("POST /sessions/:sessionKey/kill", () => {
expect(authMock).not.toHaveBeenCalled();
expect(loadSessionEntryMock).not.toHaveBeenCalled();
expect(killSubagentRunAdminMock).not.toHaveBeenCalled();
expect(killControlledSubagentRunMock).not.toHaveBeenCalled();
},
);
@@ -261,8 +236,7 @@ describe("POST /sessions/:sessionKey/kill", () => {
expect(killSubagentRunAdminMock).not.toHaveBeenCalled();
});
it("rejects remote bearer-auth kills without requester ownership", async () => {
isLocalDirectRequestMock.mockReturnValue(false);
it("rejects bearer-auth kills without a trusted admin scope surface", async () => {
mockWorkerSession();
const response = await postWorkerKill();
@@ -271,63 +245,29 @@ describe("POST /sessions/:sessionKey/kill", () => {
expect(killSubagentRunAdminMock).not.toHaveBeenCalled();
});
it("rejects remote kills without requester ownership or an authorized token", async () => {
isLocalDirectRequestMock.mockReturnValue(false);
authMock.mockResolvedValueOnce({ ok: true });
it("rejects trusted-proxy requester-session kills without admin scope", async () => {
allowTrustedProxyAuth();
const response = await postWorkerKill("", REQUESTER_WRITE_HEADERS);
await expectForbiddenMissingScope(response, "missing scope: operator.admin");
expect(loadSessionEntryMock).not.toHaveBeenCalled();
expect(killSubagentRunAdminMock).not.toHaveBeenCalled();
});
it("uses the admin kill path even when the requester session header is present", async () => {
allowTrustedProxyAuth();
mockWorkerSession();
killSubagentRunAdminMock.mockResolvedValue({ found: true, killed: true });
const response = await postWorkerKill("", {
authorization: "",
});
expect(response.status).toBe(403);
expect(killSubagentRunAdminMock).not.toHaveBeenCalled();
});
it("uses requester ownership checks when a requester session header is provided without admin bypass", async () => {
allowRemoteRequesterKill();
getLatestSubagentRunByChildSessionKeyMock.mockReturnValue({
runId: "run-1",
childSessionKey: WORKER_SESSION_KEY,
});
killControlledSubagentRunMock.mockResolvedValue({ status: "ok" });
const response = await postWorkerKill("", REQUESTER_WRITE_HEADERS);
await expectRequesterKillResponse(response, true);
expect(resolveSubagentControllerMock).toHaveBeenCalledWith({
const response = await postWorkerKill("", REQUESTER_ADMIN_HEADERS);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ ok: true, killed: true });
expect(killSubagentRunAdminMock).toHaveBeenCalledWith({
cfg,
agentSessionKey: "agent:main:main",
sessionKey: WORKER_SESSION_KEY,
});
expect(getLatestSubagentRunByChildSessionKeyMock).toHaveBeenCalledWith(WORKER_SESSION_KEY);
expect(killSubagentRunAdminMock).not.toHaveBeenCalled();
});
it("uses the newest child-session row for requester-owned kills when stale rows still exist", async () => {
allowRemoteRequesterKill();
getLatestSubagentRunByChildSessionKeyMock.mockReturnValue({
runId: "run-current-ended",
childSessionKey: WORKER_SESSION_KEY,
endedAt: Date.now() - 1,
});
killControlledSubagentRunMock.mockResolvedValue({ status: "done" });
const response = await postWorkerKill("", REQUESTER_WRITE_HEADERS);
await expectRequesterKillResponse(response, false);
expect(killControlledSubagentRunMock).toHaveBeenCalledTimes(1);
const killCall = killControlledSubagentRunMock.mock.calls.at(0)?.[0] as
| {
cfg?: unknown;
controller?: { controllerSessionKey?: string };
entry?: { runId?: string; childSessionKey?: string };
}
| undefined;
expect(killCall?.cfg).toBe(cfg);
expect(killCall?.controller?.controllerSessionKey).toBe("agent:main:main");
expect(killCall?.entry?.runId).toBe("run-current-ended");
expect(killCall?.entry?.childSessionKey).toBe(WORKER_SESSION_KEY);
});
it("rejects bearer-auth requester kills without a trusted write scope surface", async () => {
isLocalDirectRequestMock.mockReturnValue(false);
it("rejects bearer-auth requester kills without a trusted admin scope surface", async () => {
const response = await post(
"/sessions/agent%3Amain%3Asubagent%3Aworker/kill",
TEST_GATEWAY_TOKEN,
@@ -336,10 +276,9 @@ describe("POST /sessions/:sessionKey/kill", () => {
expect(response.status).toBe(403);
expectErrorResponse(await response.json(), {
type: "forbidden",
message: "missing scope: operator.write",
message: "missing scope: operator.admin",
});
expect(loadSessionEntryMock).not.toHaveBeenCalled();
expect(killSubagentRunAdminMock).not.toHaveBeenCalled();
expect(killControlledSubagentRunMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,16 +1,10 @@
// Gateway HTTP session kill handler.
// Allows local admins or owning parent sessions to stop subagent runs.
// Stops subagent runs through the admin-scoped HTTP control surface.
import type { IncomingMessage, ServerResponse } from "node:http";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
killControlledSubagentRun,
killSubagentRunAdmin,
resolveSubagentController,
} from "../agents/subagent-control.js";
import { getLatestSubagentRunByChildSessionKey } from "../agents/subagent-registry.js";
import { killSubagentRunAdmin } from "../agents/subagent-control.js";
import { getRuntimeConfig } from "../config/io.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import { isLocalDirectRequest, type ResolvedGatewayAuth } from "./auth.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import {
sendInvalidRequest,
sendJson,
@@ -24,8 +18,6 @@ import {
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
import { loadSessionEntry } from "./session-utils.js";
const REQUESTER_SESSION_KEY_HEADER = "x-openclaw-requester-session-key";
type SessionKeyPathResolution =
| { matched: false }
| { matched: true; sessionKey: string }
@@ -86,30 +78,8 @@ export async function handleSessionKillHttpRequest(
return true;
}
const trustedProxies = opts.trustedProxies ?? cfg.gateway?.trustedProxies;
const allowRealIpFallback = opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback;
const requesterSessionKey = normalizeOptionalString(
req.headers[REQUESTER_SESSION_KEY_HEADER]?.toString(),
);
const allowLocalAdminKill = isLocalDirectRequest(req, trustedProxies, allowRealIpFallback);
const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth);
// Remote browser requests must prove parent-session ownership; local direct
// operator requests can perform the stronger admin kill path.
if (!requesterSessionKey && !allowLocalAdminKill) {
sendJson(res, 403, {
ok: false,
error: {
type: "forbidden",
message: "Session kills require a local admin request or requester session ownership.",
},
});
return true;
}
const requiredOperatorMethod =
requesterSessionKey && !allowLocalAdminKill ? "sessions.abort" : "sessions.delete";
const scopeAuth = authorizeOperatorScopesForMethod(requiredOperatorMethod, requestedScopes);
const scopeAuth = authorizeOperatorScopesForMethod("sessions.delete", requestedScopes);
if (!scopeAuth.allowed) {
sendMissingScopeForbidden(res, scopeAuth.missingScope);
return true;
@@ -127,38 +97,14 @@ export async function handleSessionKillHttpRequest(
return true;
}
let killed = false;
if (!allowLocalAdminKill && requesterSessionKey) {
const runEntry = getLatestSubagentRunByChildSessionKey(canonicalKey);
if (runEntry) {
const result = await killControlledSubagentRun({
cfg,
controller: resolveSubagentController({ cfg, agentSessionKey: requesterSessionKey }),
entry: runEntry,
});
if (result.status === "forbidden") {
sendJson(res, 403, {
ok: false,
error: {
type: "forbidden",
message: result.error,
},
});
return true;
}
killed = result.status === "ok";
}
} else {
const result = await killSubagentRunAdmin({
cfg,
sessionKey: canonicalKey,
});
killed = result.killed;
}
const result = await killSubagentRunAdmin({
cfg,
sessionKey: canonicalKey,
});
sendJson(res, 200, {
ok: true,
killed,
killed: result.killed,
});
return true;
}

View File

@@ -86,6 +86,10 @@ export function formatErrorMessage(err: unknown): string {
seen.add(cause);
if (cause instanceof Error) {
appendCauseMessage(cause.message);
const code = extractErrorCode(cause);
if (code) {
appendCauseMessage(code);
}
cause = cause.cause;
} else if (typeof cause === "string") {
appendCauseMessage(cause);

View File

@@ -553,7 +553,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `${upstreamSha}\n${selectedSha}\n`, stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
key.endsWith(` ${upstreamSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -582,7 +582,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
preflightPrefixPattern.test(key)
) {
return { stdout: "", stderr: "", code: 0 };
@@ -663,7 +663,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `${selectedSha}\n`, stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
key.endsWith(` ${selectedSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -671,7 +671,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `HEAD is now at ${selectedSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/") &&
key.startsWith("git -C ") &&
preflightPrefixPattern.test(key) &&
key.includes(" checkout --detach ") &&
key.endsWith(selectedSha)
@@ -685,7 +685,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
preflightPrefixPattern.test(key)
) {
return { stdout: "", stderr: "", code: 0 };
@@ -939,7 +939,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "10.0.0", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
key.endsWith(` ${upstreamSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -947,7 +947,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/") &&
key.startsWith("git -C ") &&
preflightPrefixPattern.test(key) &&
key.includes(" checkout --detach ") &&
key.endsWith(upstreamSha)
@@ -962,7 +962,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
preflightPrefixPattern.test(key)
) {
return { stdout: "", stderr: "", code: 0 };
@@ -1033,7 +1033,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "10.0.0", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
key.endsWith(` ${upstreamSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -1041,7 +1041,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/") &&
key.startsWith("git -C ") &&
preflightPrefixPattern.test(key) &&
key.includes(" checkout --detach ") &&
key.endsWith(upstreamSha)
@@ -1058,7 +1058,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
preflightPrefixPattern.test(key)
) {
return { stdout: "", stderr: "", code: 0 };
@@ -1120,7 +1120,7 @@ describe("runGatewayUpdate", () => {
return toCommandResult(response);
}
if (
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
key.endsWith(` ${upstreamSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -1128,7 +1128,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/") &&
key.startsWith("git -C ") &&
preflightPrefixPattern.test(key) &&
key.includes(" checkout --detach ") &&
(key.endsWith(upstreamSha) || key.endsWith(selectedSha))
@@ -1149,7 +1149,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
preflightPrefixPattern.test(key)
) {
return { stdout: "", stderr: "", code: 0 };
@@ -1211,7 +1211,7 @@ describe("runGatewayUpdate", () => {
return toCommandResult(response);
}
if (
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
key.endsWith(` ${upstreamSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -1219,7 +1219,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/") &&
key.startsWith("git -C ") &&
preflightPrefixPattern.test(key) &&
key.includes(" checkout --detach ") &&
(key.endsWith(upstreamSha) || key.endsWith(olderSha))
@@ -1243,7 +1243,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "build failed", code: 1 };
}
if (
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
preflightPrefixPattern.test(key)
) {
return { stdout: "", stderr: "", code: 0 };
@@ -1473,7 +1473,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "added 1 package", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
key.endsWith(` ${upstreamSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -1481,7 +1481,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/") &&
key.startsWith("git -C ") &&
preflightPrefixPattern.test(key) &&
key.includes(" checkout --detach ") &&
key.endsWith(upstreamSha)
@@ -1494,7 +1494,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
preflightPrefixPattern.test(key)
) {
return { stdout: "", stderr: "", code: 0 };
@@ -1575,7 +1575,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "10.0.0", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
key.endsWith(` ${upstreamSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -1583,7 +1583,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/") &&
key.startsWith("git -C ") &&
preflightPrefixPattern.test(key) &&
key.includes(" checkout --detach ") &&
key.endsWith(upstreamSha)
@@ -1598,7 +1598,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
preflightPrefixPattern.test(key)
) {
return { stdout: "", stderr: "", code: 0 };
@@ -1673,7 +1673,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "10.0.0", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
key.endsWith(` ${upstreamSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -1681,7 +1681,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/") &&
key.startsWith("git -C ") &&
preflightPrefixPattern.test(key) &&
key.includes(" checkout --detach ") &&
key.endsWith(upstreamSha)
@@ -1712,7 +1712,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
preflightPrefixPattern.test(key)
) {
return { stdout: "", stderr: "", code: 0 };
@@ -1788,7 +1788,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "10.0.0", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
key.endsWith(` ${upstreamSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -1796,7 +1796,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/") &&
key.startsWith("git -C ") &&
preflightPrefixPattern.test(key) &&
key.includes(" checkout --detach ") &&
key.endsWith(upstreamSha)
@@ -1887,7 +1887,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "10.0.0", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
key.endsWith(` ${upstreamSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -1895,7 +1895,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/") &&
key.startsWith("git -C ") &&
preflightPrefixPattern.test(key) &&
key.includes(" checkout --detach ") &&
key.endsWith(upstreamSha)
@@ -1981,7 +1981,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "10.0.0", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
key.endsWith(` ${upstreamSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -1989,7 +1989,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/") &&
key.startsWith("git -C ") &&
preflightPrefixPattern.test(key) &&
key.includes(" checkout --detach ") &&
key.endsWith(upstreamSha)
@@ -2008,7 +2008,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
preflightPrefixPattern.test(key)
) {
return { stdout: "", stderr: "", code: 0 };
@@ -2068,7 +2068,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `${targetSha}\n`, stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
key.endsWith(` ${targetSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -2076,7 +2076,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `HEAD is now at ${targetSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/") &&
key.startsWith("git -C ") &&
key.includes(` checkout --detach ${targetSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -2092,7 +2092,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
preflightPrefixPattern.test(key)
) {
return { stdout: "", stderr: "", code: 0 };
@@ -2149,7 +2149,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `${targetSha}\n`, stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
key.endsWith(` ${targetSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -2157,7 +2157,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `HEAD is now at ${targetSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/") &&
key.startsWith("git -C ") &&
key.includes(` checkout --detach ${targetSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -2173,7 +2173,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
preflightPrefixPattern.test(key)
) {
return { stdout: "", stderr: "", code: 0 };
@@ -2233,7 +2233,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `${targetSha}\n`, stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${gitRoot} worktree add --detach /tmp/`) &&
key.startsWith(`git -C ${gitRoot} worktree add --detach `) &&
key.endsWith(` ${targetSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -2241,7 +2241,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `HEAD is now at ${targetSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/") &&
key.startsWith("git -C ") &&
key.includes(` checkout --detach ${targetSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -2257,7 +2257,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${gitRoot} worktree remove --force /tmp/`) &&
key.startsWith(`git -C ${gitRoot} worktree remove --force `) &&
preflightPrefixPattern.test(key)
) {
return { stdout: "", stderr: "", code: 0 };
@@ -2313,7 +2313,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `${upstreamSha}\n`, stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
key.endsWith(` ${upstreamSha}`) &&
preflightPrefixPattern.test(key)
) {
@@ -2321,7 +2321,7 @@ describe("runGatewayUpdate", () => {
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/") &&
key.startsWith("git -C ") &&
preflightPrefixPattern.test(key) &&
key.includes(" checkout --detach ") &&
key.endsWith(upstreamSha)
@@ -2329,7 +2329,7 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
preflightPrefixPattern.test(key)
) {
return { stdout: "", stderr: "", code: 0 };

View File

@@ -353,6 +353,14 @@ export type PluginHookLlmOutputEvent = {
cacheWrite?: number;
total?: number;
};
/**
* Requested reasoning/think effort for this call (provider think level, e.g.
* "off" | "low" | "medium" | "high"). Lets a passive footer show the mode the
* user is actually running without re-deriving it.
*/
reasoningEffort?: string;
/** Whether fast mode was active for this call. */
fastMode?: boolean;
};
export type PluginHookAgentEndEvent = {
@@ -494,12 +502,84 @@ export type PluginHookReplyDispatchResult = {
counts: Record<ReplyDispatchKind, number>;
};
/**
* Per-turn execution state for the outbound reply, available to every harness
* (embedded, CLI, Codex app-server) — sourced from the unified `runResult.meta`
* at dispatch, not from the harness-specific `llm_output` hook. Lets a plugin
* render a passive per-response footer without re-deriving run state.
*/
export type PluginHookReplyUsageState = {
provider?: string;
model?: string;
/** Resolved provider/model ref actually used (keeps the provider prefix). */
resolvedRef?: string;
/** Requested reasoning/think effort (e.g. "off" | "low" | "medium" | "high"). */
reasoningEffort?: string;
fastMode?: boolean;
/** True when a model fallback was used for this turn. */
fallbackUsed?: boolean;
/** Owning agent + session for this reply. */
agentId?: string;
sessionId?: string;
/** Chat surface kind (e.g. "direct" | "group"). */
chatType?: string;
/** Credential mode the turn ran under (e.g. "oauth" | "api_key"). */
authMode?: string;
/** Session model-override source, when a non-default model was pinned. */
overrideSource?: string;
/** Provider/model ref requested for the turn (vs resolvedRef actually used). */
requested?: string;
/** Estimated cost of this turn in USD, when a cost table is configured. */
turnUsd?: number;
/** Wall-clock duration of the turn in milliseconds. */
durationMs?: number;
/** Owning agent's configured identity (name/emoji/avatar), when set. */
identity?: { name?: string; emoji?: string; avatar?: string };
compactionCount?: number;
/** Effective context-token budget after model/config/agent caps. */
contextTokenBudget?: number;
/**
* Actual context-window occupancy at the END of the turn — the final model
* call's prompt tokens, NOT the per-turn aggregate. This is the value
* `context.used_tokens` / `context.pct_used` must use: the aggregate prompt
* total over a multi-call tool loop overstates occupancy (often beyond the
* window). Absent on harnesses that don't report it (the contract then falls
* back to the aggregate prompt total, which is correct for single-call turns).
*/
contextUsedTokens?: number;
usage?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
/**
* Usage from the FINAL model call of the turn only — vs `usage`, which is the
* turn aggregate summed across every tool-loop call. Lets a footer render the
* last exchange's i/o + cache instead of the whole turn. Absent on harnesses
* that don't report per-call usage.
*/
lastUsage?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
};
export type PluginHookReplyPayloadSendingEvent = {
payload: PluginHookReplyPayload;
kind: ReplyDispatchKind;
channel?: string;
sessionKey?: string;
runId?: string;
/**
* Per-turn usage snapshot for live dispatcher delivery. Absent on durable
* delivery/replay paths, and whenever no exact run correlation is available.
*/
usageState?: PluginHookReplyUsageState;
};
export type PluginHookReplyPayload = Omit<ReplyPayload, "trustedLocalMedia">;

View File

@@ -21,6 +21,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeProviderModelIdWithManifest } from "./manifest-model-id-normalization.js";
import { resolvePluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
import type { PluginMetadataRegistryView } from "./plugin-metadata-snapshot.types.js";
import { resolvePluginDiscoveryProvidersRuntime } from "./provider-discovery.runtime.js";
import {
clearProviderRuntimePluginCacheForTest,
@@ -327,6 +328,7 @@ export function normalizeProviderResolvedModelWithPlugin(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
pluginMetadataSnapshot?: PluginMetadataRegistryView;
context: {
config?: OpenClawConfig;
agentDir?: string;

View File

@@ -515,25 +515,29 @@ describe("test-projects args", () => {
).toBe(1);
});
it("keeps conservative core full-suite runs on aggregate shards", () => {
it("keeps conservative local full-suite runs on leaf project configs", () => {
const originalVitestMaxWorkers = process.env.OPENCLAW_VITEST_MAX_WORKERS;
const originalTestWorkers = process.env.OPENCLAW_TEST_WORKERS;
const originalProjectParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
const originalLeafShards = process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
const originalCi = process.env.CI;
const originalActions = process.env.GITHUB_ACTIONS;
try {
process.env.OPENCLAW_VITEST_MAX_WORKERS = "1";
delete process.env.OPENCLAW_TEST_WORKERS;
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
delete process.env.CI;
delete process.env.GITHUB_ACTIONS;
const configs = buildFullSuiteVitestRunPlans([]).map((plan) => plan.config);
expect(configs).toContain("test/vitest/vitest.full-core-unit-fast.config.ts");
expect(configs).toContain("test/vitest/vitest.full-core-support-boundary.config.ts");
expect(configs).not.toContain("test/vitest/vitest.boundary.config.ts");
expect(configs).toContain("test/vitest/vitest.full-agentic.config.ts");
expect(configs).not.toContain("test/vitest/vitest.agents.config.ts");
expect(configs).not.toContain("test/vitest/vitest.plugins.config.ts");
expect(configs).toContain("test/vitest/vitest.unit-fast.config.ts");
expect(configs).toContain("test/vitest/vitest.boundary.config.ts");
expect(configs).toContain("test/vitest/vitest.agents-core.config.ts");
expect(configs).toContain("test/vitest/vitest.plugins.config.ts");
expect(configs).not.toContain("test/vitest/vitest.full-core-unit-fast.config.ts");
expect(configs).not.toContain("test/vitest/vitest.full-agentic.config.ts");
} finally {
if (originalVitestMaxWorkers === undefined) {
delete process.env.OPENCLAW_VITEST_MAX_WORKERS;
@@ -555,6 +559,16 @@ describe("test-projects args", () => {
} else {
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = originalLeafShards;
}
if (originalCi === undefined) {
delete process.env.CI;
} else {
process.env.CI = originalCi;
}
if (originalActions === undefined) {
delete process.env.GITHUB_ACTIONS;
} else {
process.env.GITHUB_ACTIONS = originalActions;
}
}
});

View File

@@ -2143,29 +2143,25 @@ describe("scripts/test-projects full-suite sharding", () => {
).toThrow("OPENCLAW_TEST_WORKERS must be a positive integer; got: 1 worker");
});
it("keeps serial untargeted runs on aggregate shards", () => {
it("keeps serial untargeted local runs on leaf project configs", () => {
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
const previousSerial = process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
const previousCi = process.env.CI;
const previousActions = process.env.GITHUB_ACTIONS;
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
delete process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD;
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
delete process.env.CI;
delete process.env.GITHUB_ACTIONS;
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = "1";
try {
expect(buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config)).toEqual([
"test/vitest/vitest.full-core-unit-fast.config.ts",
"test/vitest/vitest.full-core-unit-src.config.ts",
"test/vitest/vitest.full-core-unit-security.config.ts",
"test/vitest/vitest.full-core-unit-ui.config.ts",
"test/vitest/vitest.full-core-unit-support.config.ts",
"test/vitest/vitest.full-core-support-boundary.config.ts",
"test/vitest/vitest.full-core-tooling.config.ts",
"test/vitest/vitest.full-core-contracts.config.ts",
"test/vitest/vitest.full-core-bundled.config.ts",
"test/vitest/vitest.full-core-runtime.config.ts",
"test/vitest/vitest.full-agentic.config.ts",
"test/vitest/vitest.full-auto-reply.config.ts",
"test/vitest/vitest.full-extensions.config.ts",
]);
const configs = buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config);
expect(configs).toContain("test/vitest/vitest.gateway-server.config.ts");
expect(configs).toContain("test/vitest/vitest.auto-reply-reply.config.ts");
expect(configs).toContain("test/vitest/vitest.extension-telegram.config.ts");
expect(configs).not.toContain("test/vitest/vitest.full-agentic.config.ts");
expect(configs).not.toContain("test/vitest/vitest.full-extensions.config.ts");
} finally {
if (previousParallel === undefined) {
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
@@ -2177,6 +2173,16 @@ describe("scripts/test-projects full-suite sharding", () => {
} else {
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = previousSerial;
}
if (previousCi === undefined) {
delete process.env.CI;
} else {
process.env.CI = previousCi;
}
if (previousActions === undefined) {
delete process.env.GITHUB_ACTIONS;
} else {
process.env.GITHUB_ACTIONS = previousActions;
}
}
});
@@ -2241,12 +2247,74 @@ describe("scripts/test-projects full-suite sharding", () => {
}
});
it("expands conservative local worker runs to leaf project configs", () => {
const previousLeafShards = process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
const previousSerial = process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
const previousCi = process.env.CI;
const previousActions = process.env.GITHUB_ACTIONS;
const previousVitestMaxWorkers = process.env.OPENCLAW_VITEST_MAX_WORKERS;
const previousTestWorkers = process.env.OPENCLAW_TEST_WORKERS;
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
delete process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
delete process.env.CI;
delete process.env.GITHUB_ACTIONS;
process.env.OPENCLAW_VITEST_MAX_WORKERS = "1";
delete process.env.OPENCLAW_TEST_WORKERS;
try {
const configs = buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config);
expect(configs).toContain("test/vitest/vitest.gateway-server.config.ts");
expect(configs).toContain("test/vitest/vitest.auto-reply-reply.config.ts");
expect(configs).not.toContain("test/vitest/vitest.full-agentic.config.ts");
} finally {
if (previousLeafShards === undefined) {
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
} else {
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = previousLeafShards;
}
if (previousParallel === undefined) {
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
} else {
process.env.OPENCLAW_TEST_PROJECTS_PARALLEL = previousParallel;
}
if (previousSerial === undefined) {
delete process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
} else {
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = previousSerial;
}
if (previousCi === undefined) {
delete process.env.CI;
} else {
process.env.CI = previousCi;
}
if (previousActions === undefined) {
delete process.env.GITHUB_ACTIONS;
} else {
process.env.GITHUB_ACTIONS = previousActions;
}
if (previousVitestMaxWorkers === undefined) {
delete process.env.OPENCLAW_VITEST_MAX_WORKERS;
} else {
process.env.OPENCLAW_VITEST_MAX_WORKERS = previousVitestMaxWorkers;
}
if (previousTestWorkers === undefined) {
delete process.env.OPENCLAW_TEST_WORKERS;
} else {
process.env.OPENCLAW_TEST_WORKERS = previousTestWorkers;
}
}
});
it("can skip the aggregate extension shard when CI runs dedicated extension shards", () => {
const previous = process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD;
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
const previousSerial = process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
const previousCi = process.env.CI;
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = "1";
process.env.CI = "true";
process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD = "1";
try {
const configs = buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config);
@@ -2269,6 +2337,11 @@ describe("scripts/test-projects full-suite sharding", () => {
} else {
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = previousSerial;
}
if (previousCi === undefined) {
delete process.env.CI;
} else {
process.env.CI = previousCi;
}
}
});

View File

@@ -513,7 +513,7 @@ describe("scoped vitest configs", () => {
it("splits auto-reply into narrower scoped buckets", () => {
const coreTestConfig = requireTestConfig(defaultAutoReplyCoreConfig);
expect(coreTestConfig.include).toEqual(["*.test.ts"]);
expect(coreTestConfig.include).toEqual(["*.test.ts", "usage-bar/*.test.ts"]);
expect(coreTestConfig.exclude).toContain("reply*.test.ts");
expect(requireTestConfig(defaultAutoReplyTopLevelConfig).include).toEqual(["reply*.test.ts"]);
expect(requireTestConfig(defaultAutoReplyReplyConfig).include).toEqual(["reply/**/*.test.ts"]);

View File

@@ -1,5 +1,8 @@
// Full-suite Vitest shard definitions used by test-projects and CI planning.
export const autoReplyCoreTestInclude = ["src/auto-reply/*.test.ts"];
export const autoReplyCoreTestInclude = [
"src/auto-reply/*.test.ts",
"src/auto-reply/usage-bar/*.test.ts",
];
export const autoReplyCoreTestExclude = ["src/auto-reply/reply*.test.ts"];

View File

@@ -51,6 +51,7 @@ function createScrollHost(
chatLastScrollTop: 0,
chatHasAutoScrolled: false,
chatUserNearBottom: true,
chatFollowLocked: false,
chatHeaderControlsHidden: false,
chatNewMessagesBelow: false,
chatIsProgrammaticScroll: false,
@@ -107,10 +108,9 @@ describe("handleChatScroll", () => {
expect(host.chatUserNearBottom).toBe(false);
});
it("sets chatUserNearBottom=false when user scrolled up past one long message (>200px <450px)", () => {
it("sets chatUserNearBottom=false when scrolled past the near-bottom threshold", () => {
const { host } = createScrollHost({});
// distanceFromBottom = 2000 - 1250 - 400 = 350 → old threshold would say "near", new says "near"
// distanceFromBottom = 2000 - 1100 - 400 = 500 → old threshold would say "not near", new also "not near"
// distanceFromBottom = 2000 - 1100 - 400 = 500 → beyond threshold
const event = createScrollEvent(2000, 1100, 400);
handleChatScroll(host, event);
expect(host.chatUserNearBottom).toBe(false);
@@ -232,6 +232,24 @@ describe("scheduleChatScroll", () => {
expect(container.scrollTop).toBe(container.scrollHeight);
});
it("uses force=true on initial load even after a previous follow lock", async () => {
const { host, container } = createScrollHost({
scrollHeight: 2000,
scrollTop: 500,
clientHeight: 400,
});
host.chatUserNearBottom = false;
host.chatFollowLocked = true;
host.chatHasAutoScrolled = false;
scheduleChatScroll(host, true);
await host.updateComplete;
expect(container.scrollTop).toBe(container.scrollHeight);
expect(host.chatFollowLocked).toBe(false);
expect(host.chatNewMessagesBelow).toBe(false);
});
it("sets chatNewMessagesBelow when not scrolling due to user position", async () => {
const { host } = createScrollHost({
scrollHeight: 2000,
@@ -248,6 +266,62 @@ describe("scheduleChatScroll", () => {
expect(host.chatNewMessagesBelow).toBe(true);
});
it("does not re-stick streaming after a user scrolls slightly up near the bottom", async () => {
const { host, container } = createScrollHost({
scrollHeight: 2000,
scrollTop: 1540,
clientHeight: 400,
});
host.chatHasAutoScrolled = true;
host.chatUserNearBottom = true;
host.chatIsProgrammaticScroll = true;
host.chatProgrammaticScrollTarget = 1800;
host.chatLastScrollTop = 1600;
handleChatScroll(host, createScrollEvent(2000, 1540, 400));
expect(host.chatFollowLocked).toBe(true);
expect(host.chatUserNearBottom).toBe(false);
scheduleChatScroll(host);
await host.updateComplete;
expect(container.scrollTop).toBe(1540);
expect(host.chatNewMessagesBelow).toBe(true);
host.chatIsProgrammaticScroll = false;
container.scrollTop = 1600;
handleChatScroll(host, createScrollEvent(2000, 1600, 400));
expect(host.chatFollowLocked).toBe(false);
expect(host.chatUserNearBottom).toBe(true);
expect(host.chatNewMessagesBelow).toBe(false);
});
it("does not re-stick streaming after a small user scroll-up near the bottom", async () => {
const { host, container } = createScrollHost({
scrollHeight: 2000,
scrollTop: 1589,
clientHeight: 400,
});
host.chatHasAutoScrolled = true;
host.chatUserNearBottom = true;
host.chatIsProgrammaticScroll = true;
host.chatProgrammaticScrollTarget = 1800;
host.chatLastScrollTop = 1600;
handleChatScroll(host, createScrollEvent(2000, 1589, 400));
expect(host.chatFollowLocked).toBe(true);
expect(host.chatUserNearBottom).toBe(false);
scheduleChatScroll(host);
await host.updateComplete;
expect(container.scrollTop).toBe(1589);
expect(host.chatNewMessagesBelow).toBe(true);
});
it("does NOT scroll automatically when chat auto-scroll is off", async () => {
const { host, container } = createScrollHost({
scrollHeight: 2000,
@@ -360,6 +434,7 @@ describe("resetChatScroll", () => {
const { host } = createScrollHost({});
host.chatHasAutoScrolled = true;
host.chatUserNearBottom = false;
host.chatFollowLocked = true;
host.chatLastScrollTop = 300;
host.chatHeaderControlsHidden = true;
@@ -367,6 +442,7 @@ describe("resetChatScroll", () => {
expect(host.chatHasAutoScrolled).toBe(false);
expect(host.chatUserNearBottom).toBe(true);
expect(host.chatFollowLocked).toBe(false);
expect(host.chatLastScrollTop).toBe(0);
expect(host.chatHeaderControlsHidden).toBe(false);
expect(host.chatIsProgrammaticScroll).toBe(false);

View File

@@ -3,6 +3,7 @@ import { normalizeChatAutoScrollMode, type ChatAutoScrollMode } from "./storage.
/** Distance (px) from the bottom within which we consider the user "near bottom". */
const NEAR_BOTTOM_THRESHOLD = 450;
const FOLLOW_REACQUIRE_THRESHOLD = 8;
const HEADER_HIDE_SCROLL_DELTA = 12;
const HEADER_SHOW_TOP_THRESHOLD = 24;
@@ -15,6 +16,7 @@ type ScrollHost = {
chatLastScrollTop: number;
chatHasAutoScrolled: boolean;
chatUserNearBottom: boolean;
chatFollowLocked: boolean;
chatHeaderControlsHidden: boolean;
chatNewMessagesBelow: boolean;
chatIsProgrammaticScroll: boolean;
@@ -85,8 +87,8 @@ export function scheduleChatScroll(
autoScrollMode === "always" ||
(autoScrollMode === "near-bottom" &&
(effectiveForce ||
host.chatUserNearBottom ||
distanceFromBottom < NEAR_BOTTOM_THRESHOLD));
(!host.chatFollowLocked &&
(host.chatUserNearBottom || distanceFromBottom < NEAR_BOTTOM_THRESHOLD))));
if (!shouldStick) {
// User is scrolled up — flag that new content arrived below.
@@ -96,6 +98,7 @@ export function scheduleChatScroll(
if (effectiveForce) {
host.chatHasAutoScrolled = true;
}
host.chatFollowLocked = false;
const smoothEnabled =
smooth &&
(typeof window === "undefined" ||
@@ -129,8 +132,8 @@ export function scheduleChatScroll(
autoScrollMode === "always" ||
(autoScrollMode === "near-bottom" &&
(effectiveForce ||
host.chatUserNearBottom ||
latestDistanceFromBottom < NEAR_BOTTOM_THRESHOLD));
(!host.chatFollowLocked &&
(host.chatUserNearBottom || latestDistanceFromBottom < NEAR_BOTTOM_THRESHOLD))));
if (!shouldStickRetry) {
return;
}
@@ -207,21 +210,29 @@ export function handleChatScroll(host: ScrollHost, event: Event) {
// Only suppress if scrollTop is still at or above the position we scrolled to;
// if it dropped below, the user scrolled up during the guard window and we must
// process the event so streaming stops pinning them back to the bottom.
const isUserScrollUp = delta < 0;
const isDeliberateScrollUp = delta < -HEADER_HIDE_SCROLL_DELTA;
if (
host.chatIsProgrammaticScroll &&
!isUserScrollUp &&
container.scrollTop >= host.chatProgrammaticScrollTarget - container.clientHeight
) {
return;
}
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
host.chatUserNearBottom = distanceFromBottom < NEAR_BOTTOM_THRESHOLD;
if (isUserScrollUp && distanceFromBottom > FOLLOW_REACQUIRE_THRESHOLD) {
host.chatFollowLocked = true;
} else if (distanceFromBottom <= FOLLOW_REACQUIRE_THRESHOLD) {
host.chatFollowLocked = false;
}
host.chatUserNearBottom = !host.chatFollowLocked && distanceFromBottom < NEAR_BOTTOM_THRESHOLD;
const hasUsefulScroll = container.scrollHeight - container.clientHeight > NEAR_BOTTOM_THRESHOLD;
if (!hasUsefulScroll || scrollTop <= HEADER_SHOW_TOP_THRESHOLD || host.chatUserNearBottom) {
host.chatHeaderControlsHidden = false;
} else if (delta > HEADER_HIDE_SCROLL_DELTA) {
host.chatHeaderControlsHidden = true;
} else if (delta < -HEADER_HIDE_SCROLL_DELTA) {
} else if (isDeliberateScrollUp) {
host.chatHeaderControlsHidden = false;
}
@@ -252,6 +263,7 @@ export function handleActivityScroll(host: ScrollHost, event: Event) {
export function resetChatScroll(host: ScrollHost) {
host.chatHasAutoScrolled = false;
host.chatUserNearBottom = true;
host.chatFollowLocked = false;
host.chatLastScrollTop = 0;
host.chatHeaderControlsHidden = false;
host.chatNewMessagesBelow = false;

View File

@@ -697,6 +697,7 @@ export class OpenClawApp extends LitElement {
chatLastScrollTop = 0;
chatHasAutoScrolled = false;
chatUserNearBottom = true;
chatFollowLocked = false;
chatIsProgrammaticScroll = false;
chatProgrammaticScrollTarget = 0;
@state() chatNewMessagesBelow = false;