Compare commits

..

54 Commits

Author SHA1 Message Date
Peter Steinberger 630f1479c4 build: prepare 2026.3.23-2
Docker Release / validate_manual_backfill (push) Has been skipped
Docker Release / approve_manual_backfill (push) Has been skipped
Docker Release / build-amd64 (push) Failing after 37s
Docker Release / create-manifest (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
2026-03-23 20:04:42 -07:00
Peter Steinberger 38137b0cf8 refactor: split tracked ClawHub update flows 2026-03-23 20:01:51 -07:00
Peter Steinberger b4e392cf9d fix: unblock runtime-api smoke checks 2026-03-24 03:00:51 +00:00
Taras Lukavyi 7ffe7e4822 fix: populate currentThreadTs in threading tool context fallback for Telegram DM topics (#52217)
When a channel plugin lacks a custom buildToolContext (e.g. Telegram),
the fallback path in buildThreadingToolContext did not set currentThreadTs
from the inbound MessageThreadId. This caused resolveTelegramAutoThreadId
to return undefined, so message tool sends without explicit threadId
would route to the main chat instead of the originating DM topic.

Fixes #52217
2026-03-24 08:27:03 +05:30
Peter Steinberger 3ae5d33799 refactor: extract cron schedule and test runner helpers 2026-03-23 19:53:43 -07:00
Taras Lukavyi d4e3babdcc fix: command auth SecretRef resolution (#52791) (thanks @Lukavyi)
* fix(command-auth): handle unresolved SecretRef in resolveAllowFrom

* fix(command-auth): fall back to config allowlists

* fix(command-auth): avoid duplicate resolution fallback

* fix(command-auth): fail closed on invalid allowlists

* fix(command-auth): isolate fallback resolution errors

* fix: record command auth SecretRef landing notes (#52791) (thanks @Lukavyi)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-24 08:21:30 +05:30
Tak Hoffman 5cd8d43af9 tests: improve boundary audit coverage and safety (#53080)
* tools: extend seam audit inventory

* tools: tighten seam audit heuristics

* tools: refine seam test matching

* tools: refine seam audit review heuristics

* style: format seam audit script

* tools: widen seam audit matcher coverage

* tools: harden seam audit coverage

* tools: tighten boundary audit matchers

* tools: ignore mocked import matches in boundary audit

* test: include native command reply seams in audit
2026-03-23 21:46:53 -05:00
Peter Steinberger a3f2fbf5a2 refactor: harden extension runtime-api seams 2026-03-23 19:39:20 -07:00
Peter Steinberger d8e77c423a test: isolate line jiti runtime smoke 2026-03-24 02:38:49 +00:00
Peter Steinberger 9e8abb468d fix: clean changelog merge duplication (#53224) (thanks @RolfHegr) 2026-03-23 19:38:04 -07:00
Peter Steinberger 0cbf6d5fed fix: land cron tz one-shot handling and prerelease config warnings (#53224) (thanks @RolfHegr) 2026-03-23 19:38:04 -07:00
Rolfy 9aac5582d6 fix(cron): make --tz work with --at for one-shot jobs
Previously, `--at` with an offset-less ISO datetime (e.g. `2026-03-23T23:00:00`)
was always interpreted as UTC, even when `--tz` was provided. This caused one-shot
jobs to fire at the wrong time.

Changes:
- `parseAt()` now accepts an optional `tz` parameter
- When `--tz` is provided with `--at`, offset-less datetimes are interpreted in
  that IANA timezone using Intl.DateTimeFormat
- Datetimes with explicit offsets (e.g. `+01:00`, `Z`) are unaffected
- Removed the guard in cron-edit that blocked `--tz` with `--at`
- Updated `--at` help text to mention `--tz` support
- Added 2 tests verifying timezone resolution and offset preservation
2026-03-23 19:38:04 -07:00
Peter Steinberger 8f9799307b test: print failed test lane output tails 2026-03-23 19:36:44 -07:00
Peter Steinberger 7f373823b0 refactor: separate exec policy and execution targets 2026-03-23 19:36:44 -07:00
Val Alexander a96eded4a0 feat(csp): support inline script hashes in Control UI CSP (#53307) thanks @BunsDev
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Co-authored-by: Nova <nova@openknot.ai>
2026-03-23 21:35:33 -05:00
Peter Steinberger e530865274 fix: preserve legacy clawhub skill updates (#53206) (thanks @drobison00) 2026-03-23 19:34:05 -07:00
Devin Robison 003752b9b3 Remove lower casing -- preserving prior behavior 2026-03-23 19:34:05 -07:00
Devin Robison a339d706c1 Formatting fixes and remove trailing dash acceptance 2026-03-23 19:34:05 -07:00
Devin Robison 40071ea23e fix: tighten skill slug validation to ASCII-only 2026-03-23 19:34:05 -07:00
Peter Steinberger 2be3c996fb test: alias all plugin-sdk subpaths in line smoke 2026-03-24 02:31:59 +00:00
Peter Steinberger d5917d37c5 test: allow line runtime api source fallback 2026-03-24 02:26:17 +00:00
Peter Steinberger 462a7a9ae6 test: allow realpath in shell planner assertions 2026-03-24 02:15:14 +00:00
Drickon 715b13547f fix(line): pre-export clashing symbols to prevent jiti TypeError on startup (#53221)
* fix(line): pre-export clashing symbols to prevent jiti TypeError on startup

When jiti CJS-transforms extensions/line/runtime-api.ts, both
export * from "openclaw/plugin-sdk/line-runtime" and the subsequent
export * from individual source files attempt to define the same 13
symbols via Object.defineProperty with configurable:false. The second
call throws TypeError: Cannot redefine property.

The root cause is that src/plugin-sdk/line-runtime.ts re-exports
these symbols directly from the extension source files, creating a
circular path back to the same files that runtime-api.ts star-exports.

Fix: add named pre-exports for all symbols that plugin-sdk/line-runtime
re-exports from this extension. Named exports register in jiti's
_exportNames map at transform time; the star re-export's hasOwnProperty
guard then skips them, preventing the duplicate Object.defineProperty.

export * reordering cannot fix this: _exportNames is only populated
by named exports, not by export *, so the guard never fires regardless
of order.

This is the same class of bug as the Matrix plugin crash described in
issues #50868, #52780, and #52891, and uses the same fix pattern as
PR #50919.

* test: add LINE runtime-api Jiti regression (#53221) (thanks @Drickon)

* test: stabilize LINE Jiti regression (#53221) (thanks @Drickon)

* test: harden LINE Jiti regression (#53221) (thanks @Drickon)

* chore: retrigger PR checks (#53221)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-23 19:13:25 -07:00
Peter Steinberger d8cef14eb1 fix: split exec and policy resolution for wrapper trust (#53134) (thanks @vincentkoc) 2026-03-23 19:04:04 -07:00
Peter Steinberger 21d480ed92 fix(infra): preserve blocked dispatch policy target
# Conflicts:
#	CHANGELOG.md
2026-03-23 19:04:04 -07:00
Vincent Koc 32e89b4687 Infra: preserve wrapper executable for multiplexer trust 2026-03-23 19:04:04 -07:00
Peter Steinberger 2d5f822ca1 fix: warn on same-base prerelease configs 2026-03-24 02:02:31 +00:00
Peter Steinberger 85ed1a8986 refactor: clean up ClawHub compatibility validation 2026-03-23 18:52:37 -07:00
Val Alexander 9dd0530b97 fix(ui): redact sensitive config values in diff panel
Use isSensitiveConfigPath to detect token/password/secret/apiKey paths
and display REDACTED_PLACEHOLDER instead of raw values in the config
diff panel, preventing credential exposure in the UI.
2026-03-23 20:48:08 -05:00
Val Alexander 21ac4b9a8a style(ui): continue ui clarity pass across theme, config, and usage (#53272) thanks @BunsDev
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Co-authored-by: Nova <nova@openknot.ai>
2026-03-23 20:45:43 -05:00
Peter Steinberger ecc8fe5dc2 ci: rebalance sharded test lanes 2026-03-24 01:44:26 +00:00
Peter Steinberger 5b4fd6bf31 fix: use runtime version for ClawHub plugin API checks (#53157) (thanks @futhgar) 2026-03-23 18:41:18 -07:00
futhgar 447e074bf4 fix(plugins): use runtime version for plugin API compatibility check
OPENCLAW_PLUGIN_API_VERSION was hardcoded to "1.2.0" while ClawHub-published
plugins require >=2026.3.22, making all plugin installs via ClawHub fail with
"requires plugin API >=2026.3.22, but this OpenClaw runtime exposes 1.2.0".

Use resolveRuntimeServiceVersion() (already imported) to read the actual
version from package.json at runtime.

Fixes #53038
2026-03-23 18:41:18 -07:00
Peter Steinberger d25ad66069 fix: resolve catalog-backed channel login 2026-03-23 18:25:44 -07:00
Peter Steinberger 69390daa51 test: cover config correction version warnings 2026-03-23 18:23:50 -07:00
Peter Steinberger b4bda479a4 fix: normalize bundled plugin version reporting 2026-03-23 18:23:50 -07:00
Peter Steinberger e9905fd696 fix: avoid fd warnings in lock exit cleanup 2026-03-24 01:01:59 +00:00
Val Alexander 6c44b2ea50 fix(cli): guard channel-auth against prototype-chain pollution and control-char injection
- Use hasOwnProperty + isBlockedObjectKey in isConfiguredAuthPlugin to
  prevent __proto__/constructor/prototype keys from matching config
- Sanitize plugin IDs with sanitizeForLog in ambiguity error messages
- Add regression test for __proto__ plugin ID
2026-03-23 19:58:16 -05:00
Val Alexander c8f4b8533d fix(cli): auto-select login-capable auth channels (#53254) thanks @BunsDev
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Co-authored-by: Nova <nova@openknot.ai>
2026-03-23 19:54:46 -05:00
Peter Steinberger 5cb8e33a31 build: tag correction npm publishes as latest 2026-03-23 17:42:43 -07:00
Peter Steinberger 00d586b2ce test: reduce flaky gemini live probe coverage 2026-03-24 00:40:17 +00:00
Peter Steinberger dc02a7520f test: stabilize moonshot and minimax live probes 2026-03-24 00:40:17 +00:00
Peter Steinberger b8bf6c482e ci: cap channel shard workers 2026-03-24 00:38:51 +00:00
Peter Steinberger 9334015262 fix: ship bundled plugin runtime sidecars 2026-03-23 17:38:08 -07:00
Peter Steinberger ffd722bc2c build: harden local release verification 2026-03-23 17:38:08 -07:00
Peter Steinberger ce75f60ae9 fix: canonicalize malformed assistant replay content 2026-03-23 17:37:51 -07:00
Val Alexander a5c35050f3 style: update border-radius values to use CSS variables for consistency across components (#53238) 2026-03-23 19:23:43 -05:00
Peter Steinberger 90fab48416 ci: stabilize sharded channel lanes 2026-03-24 00:21:50 +00:00
Peter Steinberger e32148f1dd build: publish 2026.3.23 mac appcast 2026-03-23 17:01:06 -07:00
Peter Steinberger 2d19d2acb9 ci: shorten main critical path 2026-03-23 23:45:51 +00:00
Peter Steinberger 36de481541 docs: capture windows parallels install learnings 2026-03-23 16:40:53 -07:00
Peter Steinberger ea99984e23 test: fix windows parallels agent quoting 2026-03-23 16:39:51 -07:00
Sally O'Malley 34dc712f36 changelog (#53229)
Signed-off-by: sallyom <somalley@redhat.com>
2026-03-23 19:37:59 -04:00
Peter Steinberger a0483086b9 docs: fix 2026.3.22 and 2026.3.23 release notes 2026-03-23 16:27:14 -07:00
119 changed files with 5027 additions and 1308 deletions
@@ -46,7 +46,9 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Always use `prlctl exec --current-user`; plain `prlctl exec` lands in `NT AUTHORITY\\SYSTEM`.
- Prefer explicit `npm.cmd` and `openclaw.cmd`.
- Use PowerShell only as the transport with `-ExecutionPolicy Bypass`, then call the `.cmd` shims from inside it.
- Multi-word `openclaw agent --message ...` checks should call `& $openclaw ...` inside PowerShell, not `Start-Process ... -ArgumentList` against `openclaw.cmd`, or Commander can see split argv and throw `too many arguments for 'agent'`.
- Windows installer/tgz phases now retry once after guest-ready recheck; keep new Windows smoke steps idempotent so a transport-flake retry is safe.
- Windows global `npm install -g` phases can stay quiet for a minute or more even when healthy; inspect the phase log before calling it hung, and only treat it as a regression once the retry wrapper or timeout trips.
- Keep onboarding and status output ASCII-clean in logs; fancy punctuation becomes mojibake in current capture paths.
- If you hit an older run with `rc=255` plus an empty `fresh.install-main.log` or `upgrade.install-main.log`, treat it as a likely `prlctl exec` transport drop after guest start-up, not immediate proof of an npm/package failure.
@@ -164,12 +164,24 @@ OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
`scripts/package-mac-dist.sh` to build, sign, notarize, and package the app;
manual GitHub release asset upload; then `scripts/make_appcast.sh` plus the
`appcast.xml` commit to `main`.
- `scripts/package-mac-dist.sh` now fails closed for release builds if the
bundled app comes out with a debug bundle id, an empty Sparkle feed URL, or a
`CFBundleVersion` below the canonical Sparkle build floor for that short
version. For correction tags, set a higher explicit `APP_BUILD`.
- `scripts/make_appcast.sh` first uses `generate_appcast` from `PATH`, then
falls back to the SwiftPM Sparkle tool output under `apps/macos/.build`.
- For stable tags, the local fallback may update the shared production
`appcast.xml`.
- For beta tags, the local fallback still publishes the mac assets but must not
update the shared production `appcast.xml` unless a separate beta feed exists.
- Treat the local workflow as fallback only. Prefer the CI/CD publish workflow
when it is working.
- After any stable mac publish, verify all of the following before you call the
release finished:
- the GitHub release has `.zip`, `.dmg`, and `.dSYM.zip` assets
- `appcast.xml` on `main` points at the new stable zip
- the packaged app reports the expected short version and a numeric
`CFBundleVersion` at or above the canonical Sparkle build floor
## Run the release sequence
+72
View File
@@ -0,0 +1,72 @@
name: CI Bun
on:
push:
branches: [main]
concurrency:
group: ci-bun-push-${{ github.ref_name }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
build-bun-artifacts:
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Build A2UI bundle
run: pnpm canvas:a2ui:bundle
- name: Upload A2UI bundle artifact
uses: actions/upload-artifact@v4
with:
name: canvas-a2ui-bundle
path: src/canvas-host/a2ui/
bun-checks:
needs: [build-bun-artifacts]
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- shard_index: 1
shard_count: 2
command: OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard 1/2
- shard_index: 2
shard_count: 2
command: OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard 2/2
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "true"
use-sticky-disk: "false"
- name: Download A2UI bundle artifact
uses: actions/download-artifact@v8
with:
name: canvas-a2ui-bundle
path: src/canvas-host/a2ui/
- name: Run Bun test shard
run: ${{ matrix.command }}
+72 -30
View File
@@ -7,8 +7,8 @@ on:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
concurrency:
group: ${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number) || format('ci-push-{0}', github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
group: ${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number) || format('ci-push-{0}', github.ref_name) }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -197,6 +197,14 @@ jobs:
path: dist/
retention-days: 1
- name: Upload A2UI bundle artifact
uses: actions/upload-artifact@v7
with:
name: canvas-a2ui-bundle
path: src/canvas-host/a2ui/
include-hidden-files: true
retention-days: 1
# Validate npm pack contents after build (only on push to main, not PRs).
release-check:
needs: [preflight, build-artifacts]
@@ -237,34 +245,36 @@ jobs:
task: test
shard_index: 1
shard_count: 2
command: pnpm canvas:a2ui:bundle && pnpm test
command: pnpm test
- runtime: node
task: test
shard_index: 2
shard_count: 2
command: pnpm canvas:a2ui:bundle && pnpm test
command: pnpm test
- runtime: node
task: extensions
command: pnpm test:extensions
- runtime: node
task: channels
shard_index: 1
shard_count: 3
command: pnpm test:channels
- runtime: node
task: contracts
command: pnpm test:contracts
- runtime: node
task: channels
shard_index: 2
shard_count: 3
command: pnpm test:channels
- runtime: node
task: channels
shard_index: 3
shard_count: 3
command: pnpm test:channels
- runtime: node
task: protocol
command: pnpm protocol:check
- runtime: bun
task: test
shard_index: 1
shard_count: 2
command: pnpm canvas:a2ui:bundle && OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard 1/2
- runtime: bun
task: test
shard_index: 2
shard_count: 2
command: pnpm canvas:a2ui:bundle && OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard 2/2
- runtime: node
task: compat-node22
node_version: "22.x"
@@ -279,26 +289,26 @@ jobs:
node --import tsx scripts/release-check.ts
steps:
- name: Skip compatibility lanes on pull requests
if: github.event_name == 'pull_request' && (matrix.runtime == 'bun' || matrix.task == 'compat-node22')
if: github.event_name == 'pull_request' && matrix.task == 'compat-node22'
run: echo "Skipping push-only lane on pull requests."
- name: Checkout
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
uses: ./.github/actions/setup-node-env
with:
node-version: "${{ matrix.node_version || '24.x' }}"
cache-key-suffix: "${{ matrix.cache_key_suffix || 'node24' }}"
install-bun: "${{ matrix.runtime == 'bun' }}"
install-bun: "false"
use-sticky-disk: "false"
- name: Configure Node test resources
if: (github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')) && matrix.runtime == 'node' && (matrix.task == 'test' || matrix.task == 'compat-node22')
if: (github.event_name != 'pull_request' || matrix.task != 'compat-node22') && matrix.runtime == 'node' && (matrix.task == 'test' || matrix.task == 'channels' || matrix.task == 'compat-node22')
env:
SHARD_COUNT: ${{ matrix.shard_count || '' }}
SHARD_INDEX: ${{ matrix.shard_index || '' }}
@@ -307,6 +317,10 @@ jobs:
# Default heap limits have been too low on Linux CI (V8 OOM near 4GB).
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
if [ "${{ matrix.task }}" = "channels" ]; then
echo "OPENCLAW_TEST_WORKERS=1" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_ISOLATE=1" >> "$GITHUB_ENV"
fi
if [ -n "$SHARD_COUNT" ] && [ -n "$SHARD_INDEX" ]; then
echo "OPENCLAW_TEST_SHARDS=$SHARD_COUNT" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_SHARD_INDEX=$SHARD_INDEX" >> "$GITHUB_ENV"
@@ -319,12 +333,23 @@ jobs:
name: dist-build
path: dist/
- name: Download A2UI bundle artifact
if: github.event_name == 'push' && (matrix.task == 'test' || matrix.task == 'channels')
uses: actions/download-artifact@v8
with:
name: canvas-a2ui-bundle
path: src/canvas-host/a2ui/
- name: Build A2UI bundle
if: github.event_name != 'push' && (matrix.task == 'test' || matrix.task == 'channels')
run: pnpm canvas:a2ui:bundle
- name: Build dist
if: github.event_name != 'push' && matrix.task == 'test' && matrix.runtime == 'node'
run: pnpm build
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
run: ${{ matrix.command }}
extension-fast:
@@ -585,32 +610,42 @@ jobs:
- runtime: node
task: test
shard_index: 1
shard_count: 6
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 2
shard_count: 6
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 3
shard_count: 6
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 4
shard_count: 6
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 5
shard_count: 6
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 6
shard_count: 6
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 7
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 8
shard_count: 8
command: pnpm test
steps:
- name: Checkout
@@ -683,10 +718,6 @@ jobs:
echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"
- name: Build A2UI bundle (Windows)
if: matrix.task == 'test'
run: pnpm canvas:a2ui:bundle
- name: Download dist artifact
if: github.event_name == 'push' && matrix.task == 'test'
uses: actions/download-artifact@v8
@@ -694,6 +725,17 @@ jobs:
name: dist-build
path: dist/
- name: Download A2UI bundle artifact
if: github.event_name == 'push' && matrix.task == 'test'
uses: actions/download-artifact@v8
with:
name: canvas-a2ui-bundle
path: src/canvas-host/a2ui/
- name: Build A2UI bundle (Windows)
if: github.event_name != 'push' && matrix.task == 'test'
run: pnpm canvas:a2ui:bundle
- name: Build dist (Windows)
if: github.event_name != 'push' && matrix.task == 'test'
run: pnpm build
+29 -18
View File
@@ -8,45 +8,63 @@ Docs: https://docs.openclaw.ai
### Changes
- ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878)
### Fixes
- Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev.
- Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:<package>` works again even when the recorded install was pinned to a version.
- Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516.
- Agents/failover: classify generic `api_error` payloads as retryable only when they include transient failure signals, so MiniMax-style backend failures still trigger model fallback without misclassifying billing, auth, or format/context errors. (#49611) Thanks @ayushozha.
- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata.
- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue.
## 2026.3.23
### Breaking
### Changes
- ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878)
- UI/clarity: consolidate button primitives (`btn--icon`, `btn--ghost`, `btn--xs`), refine the Knot theme to a black-and-red palette with WCAG 2.1 AA contrast, add config icons for Diagnostics/CLI/Secrets/ACP/MCP sections, replace the roundness slider with discrete stops, and improve accessibility with aria-labels across usage filters. (#53272) Thanks @BunsDev.
- CSP/Control UI: compute SHA-256 hashes for inline `<script>` blocks in the served `index.html` and include them in the `script-src` CSP directive, keeping inline scripts blocked by default while allowing explicitly hashed bootstrap code. (#53307) Thanks @BunsDev.
### Fixes
- Plugins/bundled runtimes: ship bundled plugin runtime sidecars like WhatsApp `light-runtime-api.js`, Matrix `runtime-api.js`, and other plugin runtime entry files in the npm package again, so global installs stop failing on missing bundled plugin runtime surfaces.
- CLI/channel auth: auto-select the single configured login-capable channel for `channels login`/`logout`, harden channel ids against prototype-chain and control-character abuse, and fall back cleanly to catalog-backed channel installs, so channel auth works again for single-channel setups and on-demand channel installs. (#53254) Thanks @BunsDev.
- Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516.
- Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev.
- Plugins/ClawHub: resolve plugin API compatibility against the active runtime version at install time, and add regression coverage for current `>=2026.3.22` ClawHub package checks so installs no longer fail behind the stale `1.2.0` constant. (#53157) Thanks @futhgar.
- Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:<package>` works again even when the recorded install was pinned to a version.
- Browser/Chrome MCP: wait for existing-session browser tabs to become usable after attach instead of treating the initial Chrome MCP handshake as ready, which reduces user-profile timeouts and repeated consent churn on macOS Chrome attach flows. Fixes #52930. Thanks @vincentkoc.
- Browser/CDP: reuse an already-running loopback browser after a short initial reachability miss instead of immediately falling back to relaunch detection, which fixes second-run browser start/open regressions on slower headless Linux setups. Fixes #53004. Thanks @vincentkoc.
- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020. Thanks @jzakirov.
- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.
- ClawHub/macOS auth: honor macOS auth config and XDG auth paths for saved ClawHub credentials, so `openclaw skills ...` and gateway skill browsing keep using the signed-in auth state instead of silently falling back to unauthenticated mode. Fixes #53034.
- ClawHub/macOS: read the local ClawHub login from the macOS Application Support path and still honor XDG config on macOS, so skill browsing uses the logged-in token on both default and XDG-style setups. Fixes #52949. Thanks @scoootscooob.
- ClawHub/skills: resolve the local ClawHub auth token for gateway skill browsing and switch browse-all requests to search so ClawControl stops falling into unauthenticated 429s and empty authenticated skill lists. Fixes #52949. Thanks @vincentkoc.
- Config/warnings: suppress the confusing “newer OpenClaw” warning when a config written by a same-base correction release like `2026.3.23-2` is read by `2026.3.23`, while still warning for truly newer or incompatible versions.
- CLI/cron: make `openclaw cron add|edit --at ... --tz <iana>` honor the requested local wall-clock time for offset-less one-shot datetimes, including DST boundaries, and keep `--tz` rejected for `--every`. (#53224) Thanks @RolfHegr.
- Commands/auth: stop slash-command authorization from crashing or dropping valid allowlists when channel `allowFrom` resolution hits unresolved SecretRef-backed accounts, and fail closed only for the affected provider inference path. (#52791) Thanks @Lukavyi.
- Agents/failover: classify generic `api_error` payloads as retryable only when they include transient failure signals, so MiniMax-style backend failures still trigger model fallback without misclassifying billing, auth, or format/context errors. (#49611) Thanks @ayushozha.
- LINE/runtime-api: pre-export overlapping runtime symbols before the `line-runtime` star export so jiti no longer throws `TypeError: Cannot redefine property` on startup. (#53221) Thanks @Drickon.
- Telegram/threading: populate `currentThreadTs` in the threading tool-context fallback for Telegram DM topics so thread-aware tools still receive the active topic context when the main thread metadata is missing. (#52217)
- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata.
- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue.
- Doctor/plugins: make `openclaw doctor --fix` remove stale `plugins.allow` and `plugins.entries` refs left behind after plugin removal. Thanks @sallyom
- Agents/replay: canonicalize malformed assistant transcript content before session-history sanitization so legacy or corrupted assistant turns stop crashing Pi replay and subagent recovery paths.
- ClawHub/skills: keep updating already-tracked legacy Unicode slugs after the ASCII-only slug hardening, so older installs do not get stuck behind `Invalid skill slug` errors during `openclaw skills update`. (#53206) Thanks @drobison00.
- Infra/exec trust: preserve shell-multiplexer wrapper binaries for policy checks without breaking approved-command reconstruction, so BusyBox/ToyBox allowlist and audit flows bind to the real wrapper while execution plans stay coherent. (#53134) Thanks @vincentkoc.
- Plugins/message tool: make Discord `components` and Slack `blocks` optional again, and route Feishu `message(..., media=...)` sends through the outbound media path, so pin/unpin/react flows stop failing schema validation and Feishu file/image attachments actually send. Fixes #52970 and #52962. Thanks @vincentkoc.
- Gateway/model pricing: stop `openrouter/auto` pricing refresh from recursing indefinitely during bootstrap, so OpenRouter auto routes can populate cached pricing and `usage.cost` again. Fixes #53035. Thanks @vincentkoc.
- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.
- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020. Thanks @jzakirov.
- Models/OpenAI Codex OAuth: bootstrap the env-configured HTTP/HTTPS proxy dispatcher on the stored-credential refresh path before token renewal runs, so expired Codex OAuth profiles can refresh successfully in proxy-required environments instead of locking users out after the first token expiry.
- Models/OpenAI Codex OAuth and Plugins/MiniMax OAuth: ensure env-configured HTTP/HTTPS proxy dispatchers are initialized before OAuth preflight and token exchange requests so proxy-required environments can complete MiniMax and OpenAI Codex sign-in flows again. (#52228; fixes #51619, #51569) Thanks @openperf.
- Plugins/memory-lancedb: bootstrap LanceDB into plugin runtime state on first use when the bundled npm install does not already have it, so `plugins.slots.memory="memory-lancedb"` works again after global npm installs without moving LanceDB into OpenClaw core dependencies. Fixes #26100.
- Config/plugins: treat stale unknown `plugins.allow` ids as warnings instead of fatal config errors, so recovery commands like `plugins install`, `doctor --fix`, and `status` still run when a plugin is missing locally. Fixes #52992. Thanks @vincentkoc.
- Doctor/WhatsApp: stop auto-enable from appending built-in channel ids like `whatsapp` to `plugins.allow`, so `openclaw doctor --fix` no longer writes schema-invalid plugin allowlist entries when repairing built-in channels. Fixes #52931. Thanks @vincentkoc.
- Telegram/auto-reply: preserve same-chat inbound debounce order without stranding stale busy-session followups, and keep same-key overflow turns ordered when tracked debounce keys are saturated. (#52998) Thanks @osolmaz.
- Telegram/message tool: add `asDocument` as a user-facing alias for `forceDocument` on image and GIF sends, while preserving explicit `forceDocument` precedence when both flags are present. (#52461) Thanks @bakhtiersizhaev.
- Discord/commands: return an explicit unauthorized reply for privileged native slash commands instead of falling through to Discord's misleading generic completion when auth gates reject the sender. Fixes #53041. Thanks @scoootscooob.
- Channels/catalog: let external channel catalogs override shipped fallback metadata and honor overridden npm specs during channel setup, so custom channel catalogs no longer fall back to bundled packages when a channel id matches. (#52988)
- Voice-call/Plivo: stabilize Plivo v2 replay keys so webhook retries and replay protection stop colliding on valid follow-up deliveries.
- Agents/skills: prefer the active resolved runtime snapshot for embedded skill config and env injection, so `skills.entries.<skill>.apiKey` SecretRefs resolve correctly during embedded startup instead of failing on raw source config. Fixes #53098. Thanks @vincentkoc.
- Agents/subagents: recheck timed-out worker waits against the latest runtime snapshot before sending completion events, so fast-finishing workers stop being reported as timed out when they actually succeeded. Fixes #53106. Thanks @vincentkoc.
- Agents/Anthropic: preserve latest assistant thinking and redacted-thinking block ordering during transcript image sanitization so follow-up turns do not trip Anthropic's unmodified-thinking validation. (#52961) Thanks @vincentkoc.
- Plugins/DeepSeek: refactor the bundled DeepSeek provider onto the shared single-provider plugin entry, move its coverage into the extension test lane, and keep bundled auth env-var metadata on the generated manifest path. (#48762) Thanks @07akioni.
- Plugins/Matrix: avoid duplicate `resolveMatrixAccountStringValues` runtime-api exports under Jiti so bundled Matrix installs no longer crash at startup with `Cannot redefine property: resolveMatrixAccountStringValues`. Fixes #52909 and #52891. Thanks @vincentkoc.
- Security/exec approvals: keep shell-wrapper positional-argv allowlist matching on real direct carriers only by rejecting single-quoted `$0`/`$n` tokens, disallowing newline-separated `exec`, and still accepting `exec --` carrier forms. Thanks @vincentkoc.
- Gateway/probe: stop successful gateway handshakes from timing out as unreachable while post-connect detail RPCs are still loading, so slow devices report a reachable RPC failure instead of a false negative dead gateway. Fixes #52927. Thanks @vincentkoc.
- Gateway/supervision: stop lock conflicts from crash-looping under launchd and systemd by keeping the duplicate process in a retry wait instead of exiting as a failure while another healthy gateway still owns the lock. Fixes #52922. Thanks @vincentkoc.
- Gateway/auth: require auth for canvas routes and admin scope for agent session reset, so anonymous canvas access and non-admin reset requests fail closed.
@@ -74,7 +92,6 @@ Docs: https://docs.openclaw.ai
- Agents/media replies: migrate the remaining browser, canvas, and nodes snapshot outputs onto `details.media` so generated media keeps attaching to assistant replies after the collect-then-attach refactor. (#51731) Thanks @christianklotz.
- Android/contacts search: escape literal `%` and `_` in contact-name queries so searches like `100%` or `_id` no longer match unrelated contacts through SQL `LIKE` wildcards. (#41891) Thanks @Kaneki-x.
- Gateway/usage: include reset and deleted archived session transcripts in usage totals, session discovery, and archived-only session detail fallback so the Usage view no longer undercounts rotated sessions. (#43215) Thanks @rcrick.
- Channels/inbound debounce: reserve same-conversation timer-backed flush order so a buffered run cannot be overtaken by a newer message for the same key, preventing Telegram reply desync after long debounced turns. Fixes #52982. Thanks @vincentkoc and @osolmaz.
### Changes
@@ -152,17 +169,11 @@ Docs: https://docs.openclaw.ai
- Docs/plugins: add the community DingTalk plugin listing to the docs catalog. (#29913) Thanks @sliverp.
- Docs/plugins: add the community QQbot plugin listing to the docs catalog. (#29898) Thanks @sliverp.
- Docs/plugins: add the community wecom plugin listing to the docs catalog. (#29905) Thanks @sliverp.
- Telegram/message tool: add `asDocument` as a user-facing alias for `forceDocument` on image and GIF sends, while preserving explicit `forceDocument` precedence when both flags are present. (#52461) Thanks @bakhtiersizhaev.
### Fixes
- Security/exec approvals: keep shell-wrapper positional-argv allowlist matching on real direct carriers only by rejecting single-quoted `$0`/`$n` tokens, disallowing newline-separated `exec`, and still accepting `exec --` carrier forms. Thanks @vincentkoc.
- Models/OpenAI Codex OAuth and Plugins/MiniMax OAuth: ensure env-configured HTTP/HTTPS proxy dispatchers are initialized before OAuth preflight and token exchange requests so proxy-required environments can complete MiniMax and OpenAI Codex sign-in flows again. (#52228; fixes #51619, #51569) Thanks @openperf.
- Plugins/DeepSeek: refactor the bundled DeepSeek provider onto the shared single-provider plugin entry, move its coverage into the extension test lane, and keep bundled auth env-var metadata on the generated manifest path. (#48762) Thanks @07akioni.
- Web tools/search provider lists: keep onboarding, configure, and docs provider lists alphabetical while preserving the separate runtime auto-detect precedence used for credential-based provider selection.
- Media/Windows security: block remote-host `file://` media URLs and UNC/network paths before local filesystem resolution in core media loading and adjacent prompt/sandbox attachment seams, so the next release no longer allows structured local-media inputs to trigger outbound SMB credential handshakes on Windows. Thanks @RacerZ-fighting for reporting.
- Release/npm packaging: keep previously released bundled plugins and Control UI assets in published `openclaw` npm installs, and fail release checks when those shipped artifacts are missing. Thanks @vincentkoc.
- Plugins/Matrix: avoid duplicate `resolveMatrixAccountStringValues` runtime-api exports under Jiti so bundled Matrix installs no longer crash at startup with `Cannot redefine property: resolveMatrixAccountStringValues`. Fixes #52909 and #52891. Thanks @vincentkoc.
- Gateway/discovery: fail closed on unresolved Bonjour and DNS-SD service endpoints in CLI discovery, onboarding, and `gateway status` so TXT-only hints can no longer steer routing or SSH auto-target selection. Thanks @nexrin for reporting.
- Security/pairing: bind iOS setup codes to the intended node profile and reject first-use bootstrap redemption that asks for broader roles or scopes. Thanks @tdjackey.
- Memory/core tools: register `memory_search` and `memory_get` independently so one unavailable memory tool no longer suppresses the other in new sessions. (#50198) Thanks @artwalker.
+41 -74
View File
@@ -2,6 +2,47 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.3.23</title>
<pubDate>Mon, 23 Mar 2026 16:59:51 -0700</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026032390</sparkle:version>
<sparkle:shortVersionString>2026.3.23</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.3.23</h2>
<h3>Breaking</h3>
<h3>Changes</h3>
<h3>Fixes</h3>
<ul>
<li>Browser/Chrome MCP: wait for existing-session browser tabs to become usable after attach instead of treating the initial Chrome MCP handshake as ready, which reduces user-profile timeouts and repeated consent churn on macOS Chrome attach flows. Fixes #52930. Thanks @vincentkoc.</li>
<li>Browser/CDP: reuse an already-running loopback browser after a short initial reachability miss instead of immediately falling back to relaunch detection, which fixes second-run browser start/open regressions on slower headless Linux setups. Fixes #53004. Thanks @vincentkoc.</li>
<li>ClawHub/macOS auth: honor macOS auth config and XDG auth paths for saved ClawHub credentials, so <code>openclaw skills ...</code> and gateway skill browsing keep using the signed-in auth state instead of silently falling back to unauthenticated mode. Fixes #53034.</li>
<li>ClawHub/macOS: read the local ClawHub login from the macOS Application Support path and still honor XDG config on macOS, so skill browsing uses the logged-in token on both default and XDG-style setups. Fixes #52949. Thanks @scoootscooob.</li>
<li>ClawHub/skills: resolve the local ClawHub auth token for gateway skill browsing and switch browse-all requests to search so ClawControl stops falling into unauthenticated 429s and empty authenticated skill lists. Fixes #52949. Thanks @vincentkoc.</li>
<li>Plugins/message tool: make Discord <code>components</code> and Slack <code>blocks</code> optional again, and route Feishu <code>message(..., media=...)</code> sends through the outbound media path, so pin/unpin/react flows stop failing schema validation and Feishu file/image attachments actually send. Fixes #52970 and #52962. Thanks @vincentkoc.</li>
<li>Gateway/model pricing: stop <code>openrouter/auto</code> pricing refresh from recursing indefinitely during bootstrap, so OpenRouter auto routes can populate cached pricing and <code>usage.cost</code> again. Fixes #53035. Thanks @vincentkoc.</li>
<li>Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach <code>openclaw doctor --fix</code> to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.</li>
<li>Agents/web_search: use the active runtime <code>web_search</code> provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020. Thanks @jzakirov.</li>
<li>Models/OpenAI Codex OAuth: bootstrap the env-configured HTTP/HTTPS proxy dispatcher on the stored-credential refresh path before token renewal runs, so expired Codex OAuth profiles can refresh successfully in proxy-required environments instead of locking users out after the first token expiry.</li>
<li>Plugins/memory-lancedb: bootstrap LanceDB into plugin runtime state on first use when the bundled npm install does not already have it, so <code>plugins.slots.memory="memory-lancedb"</code> works again after global npm installs without moving LanceDB into OpenClaw core dependencies. Fixes #26100.</li>
<li>Config/plugins: treat stale unknown <code>plugins.allow</code> ids as warnings instead of fatal config errors, so recovery commands like <code>plugins install</code>, <code>doctor --fix</code>, and <code>status</code> still run when a plugin is missing locally. Fixes #52992. Thanks @vincentkoc.</li>
<li>Doctor/WhatsApp: stop auto-enable from appending built-in channel ids like <code>whatsapp</code> to <code>plugins.allow</code>, so <code>openclaw doctor --fix</code> no longer writes schema-invalid plugin allowlist entries when repairing built-in channels. Fixes #52931. Thanks @vincentkoc.</li>
<li>Telegram/auto-reply: preserve same-chat inbound debounce order without stranding stale busy-session followups, and keep same-key overflow turns ordered when tracked debounce keys are saturated. (#52998) Thanks @osolmaz.</li>
<li>Discord/commands: return an explicit unauthorized reply for privileged native slash commands instead of falling through to Discord's misleading generic completion when auth gates reject the sender. Fixes #53041. Thanks @scoootscooob.</li>
<li>Channels/catalog: let external channel catalogs override shipped fallback metadata and honor overridden npm specs during channel setup, so custom channel catalogs no longer fall back to bundled packages when a channel id matches. (#52988)</li>
<li>Voice-call/Plivo: stabilize Plivo v2 replay keys so webhook retries and replay protection stop colliding on valid follow-up deliveries.</li>
<li>Agents/skills: prefer the active resolved runtime snapshot for embedded skill config and env injection, so <code>skills.entries.<skill>.apiKey</code> SecretRefs resolve correctly during embedded startup instead of failing on raw source config. Fixes #53098. Thanks @vincentkoc.</li>
<li>Agents/subagents: recheck timed-out worker waits against the latest runtime snapshot before sending completion events, so fast-finishing workers stop being reported as timed out when they actually succeeded. Fixes #53106. Thanks @vincentkoc.</li>
<li>Agents/Anthropic: preserve latest assistant thinking and redacted-thinking block ordering during transcript image sanitization so follow-up turns do not trip Anthropic's unmodified-thinking validation. (#52961) Thanks @vincentkoc.</li>
<li>Gateway/probe: stop successful gateway handshakes from timing out as unreachable while post-connect detail RPCs are still loading, so slow devices report a reachable RPC failure instead of a false negative dead gateway. Fixes #52927. Thanks @vincentkoc.</li>
<li>Gateway/supervision: stop lock conflicts from crash-looping under launchd and systemd by keeping the duplicate process in a retry wait instead of exiting as a failure while another healthy gateway still owns the lock. Fixes #52922. Thanks @vincentkoc.</li>
<li>Gateway/auth: require auth for canvas routes and admin scope for agent session reset, so anonymous canvas access and non-admin reset requests fail closed.</li>
<li>Release/install: keep previously released bundled plugins and Control UI assets in published openclaw npm installs, and fail release checks when those shipped artifacts are missing. Thanks @vincentkoc.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.23/OpenClaw-2026.3.23.zip" length="24522883" type="application/octet-stream" sparkle:edSignature="ptBgHYLBqq/TSdONYCfIB5d6aP/ij/9G0gYQ5mJI9jf8Y31sbQIh5CqpJVxEEWLTMIGQKsHQir/kXZjtRvvZAg=="/>
</item>
<item>
<title>2026.3.13</title>
<pubDate>Sat, 14 Mar 2026 05:19:48 +0000</pubDate>
@@ -170,79 +211,5 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.12/OpenClaw-2026.3.12.zip" length="23628700" type="application/octet-stream" sparkle:edSignature="o6Zdcw36l3I0jUg14H+RBqNwrhuuSsq1WMDi4tBRa1+5TC3VCVdFKZ2hzmH2Xjru9lDEzVMP8v2A6RexSbOCBQ=="/>
</item>
<item>
<title>2026.3.8-beta.1</title>
<pubDate>Mon, 09 Mar 2026 07:19:57 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026030801</sparkle:version>
<sparkle:shortVersionString>2026.3.8-beta.1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.3.8-beta.1</h2>
<h3>Changes</h3>
<ul>
<li>CLI/backup: add <code>openclaw backup create</code> and <code>openclaw backup verify</code> for local state archives, including <code>--only-config</code>, <code>--no-include-workspace</code>, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs.</li>
<li>macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext <code>gateway.remote.token</code> config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek.</li>
<li>Talk mode: add top-level <code>talk.silenceTimeoutMs</code> config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147.</li>
<li>TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit <code>agent:</code> session targets. (#39591) thanks @arceus77-7.</li>
<li>Tools/Brave web search: add opt-in <code>tools.web.search.brave.mode: "llm-context"</code> so <code>web_search</code> can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp.</li>
<li>CLI/install: include the short git commit hash in <code>openclaw --version</code> output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman.</li>
<li>CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras.</li>
<li>ACP/Provenance: add optional ACP ingress provenance metadata and visible receipt injection (<code>openclaw acp --provenance off|meta|meta+receipt</code>) so OpenClaw agents can retain and report ACP-origin context with session trace IDs. (#40473) thanks @mbelinky.</li>
<li>Tools/web search: alphabetize provider ordering across runtime selection, onboarding/configure pickers, and config metadata, so provider lists stay neutral and multi-key auto-detect now prefers Grok before Kimi. (#40259) thanks @kesku.</li>
<li>Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao.</li>
<li>Extensions/ACPX tests: move the shared runtime fixture helper from <code>src/runtime-internals/</code> to <code>src/test-utils/</code> so the test-only helper no longer looks like shipped runtime code.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1.</li>
<li>Android/Play distribution: remove self-update, background location, <code>screen.record</code>, and background mic capture from the Android app, narrow the foreground service to <code>dataSync</code> only, and clean up the legacy <code>location.enabledMode=always</code> preference migration. (#39660) Thanks @obviyus.</li>
<li>Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both <code>agent:main:main</code> and <code>agent:main:telegram:direct:<id></code> resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus.</li>
<li>Cron/Telegram announce delivery: route text-only announce jobs through the real outbound adapters after finalizing descendant output so plain Telegram targets no longer report <code>delivered: true</code> when no message actually reached Telegram. (#40575) thanks @obviyus.</li>
<li>Matrix/DM routing: add safer fallback detection for broken <code>m.direct</code> homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko.</li>
<li>Feishu/plugin onboarding: clear the short-lived plugin discovery cache before reloading the registry after installing a channel plugin, so onboarding no longer re-prompts to download Feishu immediately after a successful install. Fixes #39642. (#39752) Thanks @GazeKingNuWu.</li>
<li>Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092)</li>
<li>Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending.</li>
<li>Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @LarytheLord.</li>
<li>Browser/extension relay: add <code>browser.relayBindHost</code> so the Chrome relay can bind to an explicit non-loopback address for WSL2 and other cross-namespace setups, while preserving loopback-only defaults. (#39364) Thanks @mvanhorn.</li>
<li>Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for <code>/json/*</code> tab operations so local <code>ws://</code> / <code>wss://</code> profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150.</li>
<li>Browser/CDP: rewrite wildcard <code>ws://0.0.0.0</code> and <code>ws://[::]</code> debugger URLs from remote <code>/json/version</code> responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni.</li>
<li>Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with <code>tab not found</code>, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander.</li>
<li>macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved <code>.ts.net</code> and Tailscale Serve gateways, and set <code>TERM=dumb</code> for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman.</li>
<li>TUI/theme: detect light terminal backgrounds via <code>COLORFGBG</code> and pick a WCAG AA-compliant light palette, with <code>OPENCLAW_THEME=light|dark</code> override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.</li>
<li>Agents/openai-codex: normalize <code>gpt-5.4</code> fallback transport back to <code>openai-codex-responses</code> on <code>chatgpt.com/backend-api</code> when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline.</li>
<li>Models/openai-codex GPT-5.4 forward-compat: use the GPT-5.4 1,050,000-token context window and 128,000 max tokens for <code>openai-codex/gpt-5.4</code> instead of inheriting stale legacy Codex limits in resolver fallbacks and model listing. (#37876) thanks @yuweuii.</li>
<li>Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy <code>OPENROUTER_API_KEY</code>, <code>sk-or-...</code>, and explicit <code>perplexity.baseUrl</code> / <code>model</code> setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus.</li>
<li>Agents/failover: detect Amazon Bedrock <code>Too many tokens per day</code> quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window <code>too many tokens per request</code> errors out of the rate-limit lane. (#39377) Thanks @gambletan.</li>
<li>Mattermost replies: keep <code>root_id</code> pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda.</li>
<li>Telegram/DM partial streaming: keep DM preview lanes on real message edits instead of native draft materialization so final replies no longer flash a second duplicate copy before collapsing back to one.</li>
<li>macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared <code>inout</code> visibility mutation from <code>OverlayPanelFactory.present</code>, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH.</li>
<li>macOS Talk Mode: set the speech recognition request <code>taskHint</code> to <code>.dictation</code> for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv.</li>
<li>macOS release packaging: default <code>scripts/package-mac-app.sh</code> to universal binaries for <code>BUILD_CONFIG=release</code>, and clarify that <code>scripts/package-mac-dist.sh</code> already produces the release zip + DMG. (#33891) Thanks @cgdusek.</li>
<li>Hooks/session-memory: keep <code>/new</code> and <code>/reset</code> memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.</li>
<li>Sessions/model switch: clear stale cached <code>contextTokens</code> when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.</li>
<li>ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.</li>
<li>Docs/browser: add a layered WSL2 + Windows remote Chrome CDP troubleshooting guide, including Control UI origin pitfalls and extension-relay bind-address guidance. (#39407) Thanks @Owlock.</li>
<li>Context engine registry/bundled builds: share the registry state through a <code>globalThis</code> singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman.</li>
<li>Podman/setup: fix <code>cannot chdir: Permission denied</code> in <code>run_as_user</code> when <code>setup-podman.sh</code> is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to <code>/tmp</code> with <code>/</code> fallback. (#39435) Thanks @langdon and @jlcbk.</li>
<li>Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add <code>:Z</code> relabel to bind mounts in <code>run-openclaw-podman.sh</code> and the Quadlet template, fixing <code>EACCES</code> on Fedora/RHEL hosts. Supports <code>OPENCLAW_BIND_MOUNT_OPTIONS</code> override. (#39449) Thanks @langdon and @githubbzxs.</li>
<li>Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)</li>
<li>Docs/Changelog: correct the contributor credit for the bundled Control UI global-install fix to @LarytheLord. (#40420) Thanks @velvet-shark.</li>
<li>Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung.</li>
<li>Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc.</li>
<li>Gateway/restart timeout recovery: exit non-zero when restart-triggered shutdown drains time out so launchd/systemd restart the gateway instead of treating the failed restart as a clean stop. Landed from contributor PR #40380 by @dsantoreis. Thanks @dsantoreis.</li>
<li>Gateway/config restart guard: validate config before service start/restart and keep post-SIGUSR1 startup failures from crashing the gateway process, reducing invalid-config restart loops and macOS permission loss. Landed from contributor PR #38699 by @lml2468. Thanks @lml2468.</li>
<li>Gateway/launchd respawn detection: treat <code>XPC_SERVICE_NAME</code> as a launchd supervision hint so macOS restarts exit cleanly under launchd instead of attempting detached self-respawn. Landed from contributor PR #20555 by @dimat. Thanks @dimat.</li>
<li>Telegram/poll restart cleanup: abort the in-flight Telegram API fetch when shutdown or forced polling restarts stop a runner, preventing stale <code>getUpdates</code> long polls from colliding with the replacement runner. Landed from contributor PR #23950 by @Gkinthecodeland. Thanks @Gkinthecodeland.</li>
<li>Cron/restart catch-up staggering: limit immediate missed-job replay on startup and reschedule the deferred remainder from the post-catchup clock so restart bursts do not starve the gateway or silently skip overdue recurring jobs. Landed from contributor PR #18925 by @rexlunae. Thanks @rexlunae.</li>
<li>Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so <code>cron</code>/<code>gateway</code> tooling remains available after the owner-auth hardening narrowed direct-message ownership inference.</li>
<li>Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent.</li>
<li>MS Teams/authz: keep <code>groupPolicy: "allowlist"</code> enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent.</li>
<li>Security/system.run: bind approved <code>bun</code> and <code>deno run</code> script operands to on-disk file snapshots so post-approval script rewrites are denied before execution.</li>
<li>Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.8-beta.1/OpenClaw-2026.3.8-beta.1.zip" length="23407015" type="application/octet-stream" sparkle:edSignature="KCqhSmu4b0tHf55RqcQOHorsc55CgBI5BUmK/NTizxNq04INn/7QvsamHYQou9DbB2IW6B2nawBC4nn4au5yDA=="/>
</item>
</channel>
</rss>
+1 -1
View File
@@ -2620,7 +2620,7 @@
"exportName": "resolveCommandAuthorization",
"kind": "function",
"source": {
"line": 303,
"line": 440,
"path": "src/auto-reply/command-auth.ts"
}
},
@@ -287,7 +287,7 @@
{"declaration":"export function parseCommandArgs(command: ChatCommandDefinition, raw?: string | undefined): CommandArgs | undefined;","entrypoint":"command-auth","exportName":"parseCommandArgs","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":254,"sourcePath":"src/auto-reply/commands-registry.ts"}
{"declaration":"export function resolveCommandArgChoices(params: { command: ChatCommandDefinition; arg: CommandArgDefinition; cfg?: OpenClawConfig | undefined; provider?: string | undefined; model?: string | undefined; }): ResolvedCommandArgChoice[];","entrypoint":"command-auth","exportName":"resolveCommandArgChoices","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":316,"sourcePath":"src/auto-reply/commands-registry.ts"}
{"declaration":"export function resolveCommandArgMenu(params: { command: ChatCommandDefinition; args?: CommandArgs | undefined; cfg?: OpenClawConfig | undefined; }): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string | undefined; } | null;","entrypoint":"command-auth","exportName":"resolveCommandArgMenu","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":346,"sourcePath":"src/auto-reply/commands-registry.ts"}
{"declaration":"export function resolveCommandAuthorization(params: { ctx: MsgContext; cfg: OpenClawConfig; commandAuthorized: boolean; }): CommandAuthorization;","entrypoint":"command-auth","exportName":"resolveCommandAuthorization","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":303,"sourcePath":"src/auto-reply/command-auth.ts"}
{"declaration":"export function resolveCommandAuthorization(params: { ctx: MsgContext; cfg: OpenClawConfig; commandAuthorized: boolean; }): CommandAuthorization;","entrypoint":"command-auth","exportName":"resolveCommandAuthorization","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":440,"sourcePath":"src/auto-reply/command-auth.ts"}
{"declaration":"export function resolveCommandAuthorizedFromAuthorizers(params: { useAccessGroups: boolean; authorizers: CommandAuthorizer[]; modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff | undefined; }): boolean;","entrypoint":"command-auth","exportName":"resolveCommandAuthorizedFromAuthorizers","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":8,"sourcePath":"src/channels/command-gating.ts"}
{"declaration":"export function resolveControlCommandGate(params: { useAccessGroups: boolean; authorizers: CommandAuthorizer[]; allowTextCommands: boolean; hasControlCommand: boolean; modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff | undefined; }): { ...; };","entrypoint":"command-auth","exportName":"resolveControlCommandGate","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":31,"sourcePath":"src/channels/command-gating.ts"}
{"declaration":"export function resolveDirectDmAuthorizationOutcome(params: { isGroup: boolean; dmPolicy: string; senderAllowedForCommands: boolean; }): \"disabled\" | \"unauthorized\" | \"allowed\";","entrypoint":"command-auth","exportName":"resolveDirectDmAuthorizationOutcome","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":120,"sourcePath":"src/plugin-sdk/command-auth.ts"}
+1 -1
View File
@@ -363,7 +363,7 @@ Recurring job in a custom persistent session:
Notes:
- `schedule.kind`: `at` (`at`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`).
- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted).
- `schedule.at` accepts ISO 8601. Tool/API values without a timezone are treated as UTC; the CLI also accepts `openclaw cron add|edit --at "<offset-less-iso>" --tz <iana>` for local wall-clock one-shots.
- `everyMs` is milliseconds.
- `sessionTarget`: `"main"`, `"isolated"`, `"current"`, or `"session:<custom-id>"`.
- `"current"` is resolved to `"session:<sessionKey>"` at creation time.
+1 -1
View File
@@ -107,7 +107,7 @@ Quick rules:
- `Config path not found: agents.defaults.userTimezone` means the key is unset; heartbeat falls back to host timezone (or `activeHours.timezone` if set).
- Cron without `--tz` uses gateway host timezone.
- Heartbeat `activeHours` uses configured timezone resolution (`user`, `local`, or explicit IANA tz).
- ISO timestamps without timezone are treated as UTC for cron `at` schedules.
- Cron `at` schedules treat ISO timestamps without timezone as UTC unless you used CLI `--at "<offset-less-iso>" --tz <iana>`.
Common signatures:
+3
View File
@@ -21,6 +21,9 @@ output internal. `--deliver` remains as a deprecated alias for `--announce`.
Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after-run` to keep them.
Note: for one-shot CLI jobs, offset-less `--at` datetimes are treated as UTC unless you also pass
`--tz <iana>`, which interprets that local wall-clock time in the given timezone.
Note: recurring jobs now use exponential retry backoff after consecutive errors (30s → 1m → 5m → 15m → 60m), then return to normal schedule after the next successful run.
Note: `openclaw cron run` now returns as soon as the manual run is queued for execution. Successful responses include `{ ok: true, enqueued: true, runId }`; use `openclaw cron runs --id <job-id>` to follow the eventual outcome.
+13
View File
@@ -18,11 +18,14 @@ OpenClaw has three public release lanes:
- Stable release version: `YYYY.M.D`
- Git tag: `vYYYY.M.D`
- Stable correction release version: `YYYY.M.D-N`
- Git tag: `vYYYY.M.D-N`
- Beta prerelease version: `YYYY.M.D-beta.N`
- Git tag: `vYYYY.M.D-beta.N`
- Do not zero-pad month or day
- `latest` means the current stable npm release
- `beta` means the current prerelease npm release
- Stable correction releases also publish to npm `latest`
- Every OpenClaw release ships the npm package and macOS app together
## Release cadence
@@ -34,17 +37,27 @@ OpenClaw has three public release lanes:
## Release preflight
- Run `pnpm build` before `pnpm release:check` so the expected `dist/*` release
artifacts exist for the pack validation step
- Run `pnpm release:check` before every tagged release
- Run `RELEASE_TAG=vYYYY.M.D node --import tsx scripts/openclaw-npm-release-check.ts`
(or the matching beta/correction tag) before approval
- npm release preflight fails closed unless the tarball includes both
`dist/control-ui/index.html` and a non-empty `dist/control-ui/assets/` payload
so we do not ship an empty browser dashboard again
- Stable macOS release readiness also includes the updater surfaces:
- the GitHub release must end up with the packaged `.zip`, `.dmg`, and `.dSYM.zip`
- `appcast.xml` on `main` must point at the new stable zip after publish
- the packaged app must keep a non-debug bundle id, a non-empty Sparkle feed
URL, and a `CFBundleVersion` at or above the canonical Sparkle build floor
for that release version
## Public references
- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml)
- [`scripts/openclaw-npm-release-check.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/openclaw-npm-release-check.ts)
- [`scripts/package-mac-dist.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/package-mac-dist.sh)
- [`scripts/make_appcast.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/make_appcast.sh)
Maintainers use the private release docs in
[`openclaw/maintainers/release/README.md`](https://github.com/openclaw/maintainers/blob/main/release/README.md)
+222
View File
@@ -0,0 +1,222 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import { loadRuntimeApiExportTypesViaJiti } from "../../test/helpers/extensions/jiti-runtime-api.ts";
function normalizeModuleSpecifier(specifier: string): string | null {
if (specifier.startsWith("./src/")) {
return specifier;
}
if (specifier.startsWith("../../extensions/line/src/")) {
return `./src/${specifier.slice("../../extensions/line/src/".length)}`;
}
return null;
}
function collectModuleExportNames(filePath: string): string[] {
const sourcePath = filePath.replace(/\.js$/, ".ts");
const sourceText = readFileSync(sourcePath, "utf8");
const sourceFile = ts.createSourceFile(sourcePath, sourceText, ts.ScriptTarget.Latest, true);
const names = new Set<string>();
for (const statement of sourceFile.statements) {
if (
ts.isExportDeclaration(statement) &&
statement.exportClause &&
ts.isNamedExports(statement.exportClause)
) {
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) {
names.add(element.name.text);
}
}
continue;
}
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
const isExported = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
if (!isExported) {
continue;
}
if (ts.isVariableStatement(statement)) {
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
names.add(declaration.name.text);
}
}
continue;
}
if (
ts.isFunctionDeclaration(statement) ||
ts.isClassDeclaration(statement) ||
ts.isEnumDeclaration(statement)
) {
if (statement.name) {
names.add(statement.name.text);
}
}
}
return Array.from(names).toSorted();
}
function collectRuntimeApiOverlapExports(params: {
lineRuntimePath: string;
runtimeApiPath: string;
}): string[] {
const runtimeApiSource = readFileSync(params.runtimeApiPath, "utf8");
const runtimeApiFile = ts.createSourceFile(
params.runtimeApiPath,
runtimeApiSource,
ts.ScriptTarget.Latest,
true,
);
const runtimeApiLocalModules = new Set<string>();
let pluginSdkLineRuntimeSeen = false;
for (const statement of runtimeApiFile.statements) {
if (!ts.isExportDeclaration(statement)) {
continue;
}
const moduleSpecifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
if (!moduleSpecifier) {
continue;
}
if (moduleSpecifier === "openclaw/plugin-sdk/line-runtime") {
pluginSdkLineRuntimeSeen = true;
continue;
}
if (!pluginSdkLineRuntimeSeen) {
continue;
}
const normalized = normalizeModuleSpecifier(moduleSpecifier);
if (normalized) {
runtimeApiLocalModules.add(normalized);
}
}
const lineRuntimeSource = readFileSync(params.lineRuntimePath, "utf8");
const lineRuntimeFile = ts.createSourceFile(
params.lineRuntimePath,
lineRuntimeSource,
ts.ScriptTarget.Latest,
true,
);
const overlapExports = new Set<string>();
for (const statement of lineRuntimeFile.statements) {
if (!ts.isExportDeclaration(statement)) {
continue;
}
const moduleSpecifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
const normalized = moduleSpecifier ? normalizeModuleSpecifier(moduleSpecifier) : null;
if (!normalized || !runtimeApiLocalModules.has(normalized)) {
continue;
}
if (!statement.exportClause) {
for (const name of collectModuleExportNames(
path.join(process.cwd(), "extensions", "line", normalized),
)) {
overlapExports.add(name);
}
continue;
}
if (!ts.isNamedExports(statement.exportClause)) {
continue;
}
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) {
overlapExports.add(element.name.text);
}
}
}
return Array.from(overlapExports).toSorted();
}
function collectRuntimeApiPreExports(runtimeApiPath: string): string[] {
const runtimeApiSource = readFileSync(runtimeApiPath, "utf8");
const runtimeApiFile = ts.createSourceFile(
runtimeApiPath,
runtimeApiSource,
ts.ScriptTarget.Latest,
true,
);
const preExports = new Set<string>();
for (const statement of runtimeApiFile.statements) {
if (!ts.isExportDeclaration(statement)) {
continue;
}
const moduleSpecifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
if (!moduleSpecifier) {
continue;
}
if (moduleSpecifier === "openclaw/plugin-sdk/line-runtime") {
break;
}
const normalized = normalizeModuleSpecifier(moduleSpecifier);
if (!normalized || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) {
continue;
}
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) {
preExports.add(element.name.text);
}
}
}
return Array.from(preExports).toSorted();
}
describe("line runtime api", () => {
it("loads through Jiti without duplicate export errors", () => {
const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts");
expect(
loadRuntimeApiExportTypesViaJiti({
modulePath: runtimeApiPath,
exportNames: [
"buildTemplateMessageFromPayload",
"downloadLineMedia",
"isSenderAllowed",
"probeLineBot",
"pushMessageLine",
],
}),
).toEqual({
buildTemplateMessageFromPayload: "function",
downloadLineMedia: "function",
isSenderAllowed: "function",
probeLineBot: "function",
pushMessageLine: "function",
});
}, 240_000);
it("keeps the LINE pre-export block aligned with plugin-sdk/line-runtime overlap", () => {
const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts");
const lineRuntimePath = path.join(process.cwd(), "src", "plugin-sdk", "line-runtime.ts");
expect(collectRuntimeApiPreExports(runtimeApiPath)).toEqual(
collectRuntimeApiOverlapExports({
lineRuntimePath,
runtimeApiPath,
}),
);
});
});
+28
View File
@@ -23,6 +23,34 @@ export {
setSetupChannelEnabled,
splitSetupEntries,
} from "openclaw/plugin-sdk/setup";
// Pre-export all symbols that src/plugin-sdk/line-runtime.ts re-exports from this
// extension's source files. These named exports register the symbols in jiti's
// _exportNames map at transform time. The star re-export below then skips them
// via the hasOwnProperty guard, preventing a second Object.defineProperty call
// with configurable:false that would throw TypeError: Cannot redefine property.
//
// If src/plugin-sdk/line-runtime.ts gains new re-exports from extension source
// files, add matching named exports here to keep the two files in sync.
// See: src/plugin-sdk/line-runtime.ts for the authoritative list.
export {
firstDefined,
isSenderAllowed,
normalizeAllowFrom,
normalizeDmAllowFromWithStore,
} from "./src/bot-access.js";
export { downloadLineMedia } from "./src/download.js";
export { probeLineBot } from "./src/probe.js";
export { buildTemplateMessageFromPayload } from "./src/template-messages.js";
export {
createQuickReplyItems,
pushFlexMessage,
pushLocationMessage,
pushMessageLine,
pushMessagesLine,
pushTemplateMessage,
pushTextMessageWithQuickReplies,
sendMessageLine,
} from "./src/send.js";
export * from "openclaw/plugin-sdk/line-runtime";
export * from "./src/accounts.js";
+1 -1
View File
@@ -5,7 +5,7 @@ import {
normalizeAccountId,
resolveLineAccount,
type LineConfig,
} from "../runtime-api.js";
} from "./setup-runtime-api.js";
const channel = "line" as const;
+9
View File
@@ -0,0 +1,9 @@
export {
DEFAULT_ACCOUNT_ID,
formatDocsLink,
setSetupChannelEnabled,
splitSetupEntries,
} from "openclaw/plugin-sdk/setup";
export type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
export { listLineAccountIds, normalizeAccountId, resolveLineAccount } from "./accounts.js";
export type { LineConfig } from "./types.js";
+7 -7
View File
@@ -3,6 +3,12 @@ import {
createStandardChannelSetupStatus,
createTopLevelChannelDmPolicy,
} from "openclaw/plugin-sdk/setup";
import {
isLineConfigured,
listLineAccountIds,
parseLineAllowFromId,
patchLineAccountConfig,
} from "./setup-core.js";
import {
DEFAULT_ACCOUNT_ID,
formatDocsLink,
@@ -11,13 +17,7 @@ import {
splitSetupEntries,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
} from "../runtime-api.js";
import {
isLineConfigured,
listLineAccountIds,
parseLineAllowFromId,
patchLineAccountConfig,
} from "./setup-core.js";
} from "./setup-runtime-api.js";
const channel = "line" as const;
+19 -24
View File
@@ -1,10 +1,6 @@
import path from "node:path";
import { createJiti } from "jiti";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildPluginLoaderJitiOptions,
resolvePluginSdkScopedAliasMap,
} from "../../src/plugins/sdk-alias.ts";
import { loadRuntimeApiExportTypesViaJiti } from "../../test/helpers/extensions/jiti-runtime-api.ts";
const setMatrixRuntimeMock = vi.hoisted(() => vi.fn());
const registerChannelMock = vi.hoisted(() => vi.fn());
@@ -22,16 +18,17 @@ describe("matrix plugin registration", () => {
it("loads the matrix runtime api through Jiti", () => {
const runtimeApiPath = path.join(process.cwd(), "extensions", "matrix", "runtime-api.ts");
const jiti = createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(
resolvePluginSdkScopedAliasMap({ modulePath: runtimeApiPath }),
),
tryNative: false,
});
expect(jiti(runtimeApiPath)).toMatchObject({
requiresExplicitMatrixDefaultAccount: expect.any(Function),
resolveMatrixDefaultOrOnlyAccountId: expect.any(Function),
expect(
loadRuntimeApiExportTypesViaJiti({
modulePath: runtimeApiPath,
exportNames: [
"requiresExplicitMatrixDefaultAccount",
"resolveMatrixDefaultOrOnlyAccountId",
],
}),
).toEqual({
requiresExplicitMatrixDefaultAccount: "function",
resolveMatrixDefaultOrOnlyAccountId: "function",
});
}, 240_000);
@@ -43,15 +40,13 @@ describe("matrix plugin registration", () => {
"src",
"runtime-api.ts",
);
const jiti = createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(
resolvePluginSdkScopedAliasMap({ modulePath: runtimeApiPath }),
),
tryNative: false,
});
expect(jiti(runtimeApiPath)).toMatchObject({
resolveMatrixAccountStringValues: expect.any(Function),
expect(
loadRuntimeApiExportTypesViaJiti({
modulePath: runtimeApiPath,
exportNames: ["resolveMatrixAccountStringValues"],
}),
).toEqual({
resolveMatrixAccountStringValues: "function",
});
}, 240_000);
+1
View File
@@ -586,6 +586,7 @@
"android:test": "cd apps/android && ./gradlew :app:testPlayDebugUnitTest",
"android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts",
"android:test:third-party": "cd apps/android && ./gradlew :app:testThirdPartyDebugUnitTest",
"audit:seams": "node scripts/audit-seams.mjs",
"build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:docker": "node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
@@ -8,11 +8,29 @@ import { optionalBundledClusterSet } from "./lib/optional-bundled-clusters.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const srcRoot = path.join(repoRoot, "src");
const extensionsRoot = path.join(repoRoot, "extensions");
const testRoot = path.join(repoRoot, "test");
const workspacePackagePaths = ["ui/package.json"];
const MAX_SCAN_BYTES = 2 * 1024 * 1024;
const compareStrings = (left, right) => left.localeCompare(right);
const HELP_TEXT = `Usage: node scripts/audit-seams.mjs [--help]
Audit repo seam inventory and emit JSON to stdout.
Sections:
duplicatedSeamFamilies Plugin SDK seam families imported from multiple production files
overlapFiles Production files that touch multiple seam families
optionalClusterStaticLeaks Optional extension/plugin clusters referenced from the static graph
missingPackages Workspace packages whose deps are not mirrored at the root
seamTestInventory High-signal seam candidates with nearby-test gap signals
Notes:
- Output is JSON only.
- For clean redirected JSON through package scripts, prefer:
pnpm --silent audit:seams > seam-inventory.json
`;
async function collectWorkspacePackagePaths() {
const extensionsRoot = path.join(repoRoot, "extensions");
const entries = await fs.readdir(extensionsRoot, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
@@ -25,17 +43,47 @@ function normalizePath(filePath) {
return path.relative(repoRoot, filePath).split(path.sep).join("/");
}
async function readScannableText(filePath, maxBytes = MAX_SCAN_BYTES) {
const stat = await fs.stat(filePath);
if (stat.size <= maxBytes) {
return fs.readFile(filePath, "utf8");
}
const handle = await fs.open(filePath, "r");
try {
const buffer = Buffer.alloc(maxBytes);
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
await handle.close();
}
}
function redactNpmSpec(npmSpec) {
if (typeof npmSpec !== "string") {
return npmSpec ?? null;
}
return npmSpec
.replace(/(https?:\/\/)([^/\s:@]+):([^/\s@]+)@/gi, "$1***:***@")
.replace(/(https?:\/\/)([^/\s:@]+)@/gi, "$1***@");
}
function isCodeFile(fileName) {
return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(fileName);
}
function isProductionLikeFile(relativePath) {
function isTestLikePath(relativePath) {
return (
!/(^|\/)(__tests__|fixtures)\//.test(relativePath) &&
!/\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(relativePath)
/(^|\/)(__tests__|fixtures|test-utils|test-fixtures)\//.test(relativePath) ||
/(?:^|\/)[^/]*(?:[.-](?:test|spec))(?:[.-][^/]+)?\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(
relativePath,
)
);
}
function isProductionLikeFile(relativePath) {
return !isTestLikePath(relativePath);
}
async function walkCodeFiles(rootDir) {
const out = [];
async function walk(dir) {
@@ -63,6 +111,41 @@ async function walkCodeFiles(rootDir) {
return out.toSorted((left, right) => normalizePath(left).localeCompare(normalizePath(right)));
}
async function walkAllCodeFiles(rootDir, options = {}) {
const out = [];
const includeTests = options.includeTests === true;
async function walk(dir) {
let entries = [];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name === "dist" || entry.name === "node_modules") {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
continue;
}
if (!entry.isFile() || !isCodeFile(entry.name)) {
continue;
}
const relativePath = normalizePath(fullPath);
if (!includeTests && !isProductionLikeFile(relativePath)) {
continue;
}
out.push(fullPath);
}
}
await walk(rootDir);
return out.toSorted((left, right) => normalizePath(left).localeCompare(normalizePath(right)));
}
function toLine(sourceFile, node) {
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
}
@@ -253,14 +336,21 @@ function buildDuplicatedSeamFamilies(inventory) {
return [
family,
{
count: entries.length,
count: files.length,
importCount: entries.length,
files,
imports: entries,
},
];
})
.filter(([, value]) => value.files.length > 1)
.toSorted((left, right) => right[1].count - left[1].count || left[0].localeCompare(right[0])),
.toSorted((left, right) => {
return (
right[1].count - left[1].count ||
right[1].importCount - left[1].importCount ||
left[0].localeCompare(right[0])
);
}),
);
return duplicated;
@@ -338,6 +428,13 @@ function packageClusterMeta(relativePackagePath) {
}
function classifyMissingPackageCluster(params) {
if (params.hasStaticLeak) {
return {
decision: "required",
reason:
"Cluster already appears in the static graph in this audit run, so treating it as optional would be misleading.",
};
}
if (optionalBundledClusterSet.has(params.cluster)) {
if (params.cluster === "ui") {
return {
@@ -366,7 +463,7 @@ function classifyMissingPackageCluster(params) {
};
}
async function buildMissingPackages() {
async function buildMissingPackages(params = {}) {
const rootPackage = JSON.parse(await fs.readFile(path.join(repoRoot, "package.json"), "utf8"));
const rootDeps = new Set([
...Object.keys(rootPackage.dependencies ?? {}),
@@ -409,6 +506,7 @@ async function buildMissingPackages() {
const classification = classifyMissingPackageCluster({
cluster: meta.cluster,
pluginSdkEntries,
hasStaticLeak: params.staticLeakClusters?.has(meta.cluster) === true,
});
output.push({
cluster: meta.cluster,
@@ -416,7 +514,7 @@ async function buildMissingPackages() {
decisionReason: classification.reason,
packageName: pkg.name ?? meta.packageName,
packagePath: relativePackagePath,
npmSpec: pkg.openclaw?.install?.npmSpec ?? null,
npmSpec: redactNpmSpec(pkg.openclaw?.install?.npmSpec),
private: pkg.private === true,
pluginSdkReachability:
pluginSdkEntries.length > 0 ? { staticEntryPoints: pluginSdkEntries } : undefined,
@@ -429,14 +527,260 @@ async function buildMissingPackages() {
});
}
function stemFromRelativePath(relativePath) {
return relativePath.replace(/\.(m|c)?[jt]sx?$/, "");
}
function describeSeamKinds(relativePath, source) {
const seamKinds = [];
const isReplyDeliveryPath =
/reply-delivery|reply-dispatcher|deliver-reply|reply\/.*delivery|monitor\/(?:replies|deliver|native-command)|outbound\/deliver|outbound\/message/.test(
relativePath,
);
const isChannelMediaAdapterPath =
(relativePath.startsWith("extensions/") &&
/(outbound|outbound-adapter|reply-delivery|send|delivery|messenger|channel(?:\.runtime)?)\.ts$/.test(
relativePath,
)) ||
/^src\/channels\/plugins\/outbound\/[^/]+\.ts$/.test(relativePath);
if (
relativePath.startsWith("src/agents/tools/") &&
source.includes("details") &&
source.includes("media") &&
/details\s*:\s*{[\s\S]*\bmedia\b\s*:/.test(source)
) {
seamKinds.push("tool-result-media");
}
if (
isReplyDeliveryPath &&
/\bmediaUrl\b|\bmediaUrls\b|resolveSendableOutboundReplyParts/.test(source)
) {
seamKinds.push("reply-delivery-media");
}
if (
isChannelMediaAdapterPath &&
(/sendMedia\b/.test(source) || /\bmediaUrl\b|\bmediaUrls\b|filename|audioAsVoice/.test(source))
) {
seamKinds.push("channel-media-adapter");
}
if (
isReplyDeliveryPath &&
/blockStreamingEnabled|directlySentBlockKeys/.test(source) &&
/\bmediaUrl\b|\bmediaUrls\b/.test(source)
) {
seamKinds.push("streaming-media-handoff");
}
return [...new Set(seamKinds)].toSorted(compareStrings);
}
async function buildTestIndex(testFiles) {
return Promise.all(
testFiles.map(async (filePath) => {
const relativePath = normalizePath(filePath);
const stem = stemFromRelativePath(relativePath)
.replace(/\.test$/, "")
.replace(/\.spec$/, "");
const baseName = path.basename(stem);
const source = await readScannableText(filePath);
return {
filePath,
relativePath,
stem,
baseName,
source,
};
}),
);
}
function splitNameTokens(name) {
return name
.split(/[^a-zA-Z0-9]+/)
.map((token) => token.trim().toLowerCase())
.filter(Boolean);
}
function escapeForRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function hasExecutableImportReference(source, importPath) {
const escapedImportPath = escapeForRegExp(importPath);
const suffix = String.raw`(?:\.[^"'\\\`]+)?`;
const patterns = [
new RegExp(String.raw`\bfrom\s*["'\`]${escapedImportPath}${suffix}["'\`]`),
new RegExp(String.raw`\bimport\s*["'\`]${escapedImportPath}${suffix}["'\`]`),
new RegExp(String.raw`\brequire\s*\(\s*["'\`]${escapedImportPath}${suffix}["'\`]\s*\)`),
new RegExp(String.raw`\bimport\s*\(\s*["'\`]${escapedImportPath}${suffix}["'\`]\s*\)`),
];
return patterns.some((pattern) => pattern.test(source));
}
function hasModuleMockReference(source, importPath) {
const escapedImportPath = escapeForRegExp(importPath);
const suffix = String.raw`(?:\.[^"'\\\`]+)?`;
const patterns = [
new RegExp(String.raw`\bvi\.mock\s*\(\s*["'\`]${escapedImportPath}${suffix}["'\`]`),
new RegExp(String.raw`\bjest\.mock\s*\(\s*["'\`]${escapedImportPath}${suffix}["'\`]`),
];
return patterns.some((pattern) => pattern.test(source));
}
function matchQualityRank(quality) {
switch (quality) {
case "exact-stem":
return 0;
case "path-nearby":
return 1;
case "direct-import":
return 2;
case "dir-token":
return 3;
default:
return 4;
}
}
function findRelatedTests(relativePath, testIndex) {
const stem = stemFromRelativePath(relativePath);
const baseName = path.basename(stem);
const dirName = path.dirname(relativePath);
const normalizedDir = dirName.split(path.sep).join("/");
const baseTokens = new Set(splitNameTokens(baseName).filter((token) => token.length >= 7));
const matches = testIndex.flatMap((entry) => {
if (entry.stem === stem) {
return [{ file: entry.relativePath, matchQuality: "exact-stem" }];
}
if (entry.stem.startsWith(`${stem}.`)) {
return [{ file: entry.relativePath, matchQuality: "path-nearby" }];
}
const entryDir = path.dirname(entry.relativePath).split(path.sep).join("/");
const importPath =
path.posix.relative(entryDir, stem) === path.basename(stem)
? `./${path.basename(stem)}`
: path.posix.relative(entryDir, stem).startsWith(".")
? path.posix.relative(entryDir, stem)
: `./${path.posix.relative(entryDir, stem)}`;
if (
hasExecutableImportReference(entry.source, importPath) &&
!hasModuleMockReference(entry.source, importPath)
) {
return [{ file: entry.relativePath, matchQuality: "direct-import" }];
}
if (entryDir === normalizedDir && baseTokens.size > 0) {
const entryTokens = splitNameTokens(entry.baseName);
const sharedToken = entryTokens.find((token) => baseTokens.has(token));
if (sharedToken) {
return [{ file: entry.relativePath, matchQuality: "dir-token" }];
}
}
return [];
});
const byFile = new Map();
for (const match of matches) {
const existing = byFile.get(match.file);
if (
!existing ||
matchQualityRank(match.matchQuality) < matchQualityRank(existing.matchQuality)
) {
byFile.set(match.file, match);
}
}
return [...byFile.values()].toSorted((left, right) => {
return (
matchQualityRank(left.matchQuality) - matchQualityRank(right.matchQuality) ||
left.file.localeCompare(right.file)
);
});
}
function determineSeamTestStatus(seamKinds, relatedTestMatches) {
if (relatedTestMatches.length === 0) {
return {
status: "gap",
reason: "No nearby test file references this seam candidate.",
};
}
const bestMatch = relatedTestMatches[0]?.matchQuality ?? "unknown";
if (
seamKinds.includes("reply-delivery-media") ||
seamKinds.includes("streaming-media-handoff") ||
seamKinds.includes("tool-result-media")
) {
return {
status: "partial",
reason: `Nearby tests exist (best match: ${bestMatch}), but this inventory does not prove cross-layer seam coverage end to end.`,
};
}
return {
status: "heuristic-nearby",
reason: `Nearby tests exist (best match: ${bestMatch}), but this remains a filename/path heuristic rather than proof of seam assertions.`,
};
}
async function buildSeamTestInventory() {
const productionFiles = [
...(await walkCodeFiles(srcRoot)),
...(await walkCodeFiles(extensionsRoot)),
].toSorted((left, right) => normalizePath(left).localeCompare(normalizePath(right)));
const testFiles = [
...(await walkAllCodeFiles(srcRoot, { includeTests: true })),
...(await walkAllCodeFiles(extensionsRoot, { includeTests: true })),
...(await walkAllCodeFiles(testRoot, { includeTests: true })),
]
.filter((filePath) => /\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(filePath))
.toSorted((left, right) => normalizePath(left).localeCompare(normalizePath(right)));
const testIndex = await buildTestIndex(testFiles);
const inventory = [];
for (const filePath of productionFiles) {
const relativePath = normalizePath(filePath);
const source = await readScannableText(filePath);
const seamKinds = describeSeamKinds(relativePath, source);
if (seamKinds.length === 0) {
continue;
}
const relatedTestMatches = findRelatedTests(relativePath, testIndex);
const status = determineSeamTestStatus(seamKinds, relatedTestMatches);
inventory.push({
file: relativePath,
seamKinds,
relatedTests: relatedTestMatches.map((entry) => entry.file),
relatedTestMatches,
status: status.status,
reason: status.reason,
});
}
return inventory.toSorted((left, right) => {
return (
left.status.localeCompare(right.status) ||
left.file.localeCompare(right.file) ||
left.seamKinds.join(",").localeCompare(right.seamKinds.join(","))
);
});
}
const args = new Set(process.argv.slice(2));
if (args.has("--help") || args.has("-h")) {
process.stdout.write(`${HELP_TEXT}\n`);
process.exit(0);
}
await collectWorkspacePackagePaths();
const inventory = await collectCorePluginSdkImports();
const optionalClusterStaticLeaks = await collectOptionalClusterStaticLeaks();
const staticLeakClusters = new Set(optionalClusterStaticLeaks.map((entry) => entry.cluster));
const result = {
duplicatedSeamFamilies: buildDuplicatedSeamFamilies(inventory),
overlapFiles: buildOverlapFiles(inventory),
optionalClusterStaticLeaks: buildOptionalClusterStaticLeaks(optionalClusterStaticLeaks),
missingPackages: await buildMissingPackages(),
missingPackages: await buildMissingPackages({ staticLeakClusters }),
seamTestInventory: await buildSeamTestInventory(),
};
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
+15 -20
View File
@@ -333,32 +333,24 @@ guest_run_openclaw() {
local env_value="${2:-}"
shift 2
local args_literal stdout_name stderr_name env_name_q env_value_q
local args_literal env_name_q env_value_q
args_literal="$(ps_array_literal "$@")"
stdout_name="openclaw-stdout-$RANDOM-$RANDOM.log"
stderr_name="openclaw-stderr-$RANDOM-$RANDOM.log"
env_name_q="$(ps_single_quote "$env_name")"
env_value_q="$(ps_single_quote "$env_value")"
guest_powershell "$(cat <<EOF
\$stdout = Join-Path \$env:TEMP '$stdout_name'
\$stderr = Join-Path \$env:TEMP '$stderr_name'
try {
if ('${env_name_q}' -ne '') {
Set-Item -Path ('Env:' + '${env_name_q}') -Value '${env_value_q}'
}
\$proc = Start-Process -FilePath (Join-Path \$env:APPDATA 'npm\openclaw.cmd') -ArgumentList $args_literal -NoNewWindow -PassThru -RedirectStandardOutput \$stdout -RedirectStandardError \$stderr
\$proc.WaitForExit()
if (Test-Path \$stdout) {
Get-Content \$stdout
}
if (Test-Path \$stderr) {
Get-Content \$stderr
}
exit \$proc.ExitCode
} finally {
Remove-Item \$stdout, \$stderr -Force -ErrorAction SilentlyContinue
\$openclaw = Join-Path \$env:APPDATA 'npm\openclaw.cmd'
\$args = $args_literal
if ('${env_name_q}' -ne '') {
Set-Item -Path ('Env:' + '${env_name_q}') -Value '${env_value_q}'
}
# openclaw.cmd preserves multi-word --message args reliably here; Start-Process
# against the shim can re-split argv and make Commander reject the turn.
\$output = & \$openclaw @args 2>&1
if (\$null -ne \$output) {
\$output | ForEach-Object { \$_ }
}
exit \$LASTEXITCODE
EOF
)"
}
@@ -762,6 +754,9 @@ install_main_tgz() {
local temp_name="$2"
local tgz_url
tgz_url="http://$host_ip:$HOST_PORT/$(basename "$MAIN_TGZ_PATH")"
# Global npm installs on the Windows guest can stay silent for long stretches.
# Treat the phase log plus retry wrapper as the primary signal before assuming
# the guest hung.
run_windows_retry "main tgz install" 2 \
guest_exec cmd.exe /d /s /c "set \"PATH=%LOCALAPPDATA%\\OpenClaw\\deps\\portable-git\\cmd;%LOCALAPPDATA%\\OpenClaw\\deps\\portable-git\\mingw64\\bin;%LOCALAPPDATA%\\OpenClaw\\deps\\portable-git\\usr\\bin;%PATH%\" && curl.exe -fsSL \"$tgz_url\" -o \"%TEMP%\\$temp_name\" && npm.cmd install -g \"%TEMP%\\$temp_name\" --no-fund --no-audit && \"%APPDATA%\\npm\\openclaw.cmd\" --version"
}
+41 -1
View File
@@ -2,6 +2,8 @@ import fs from "node:fs";
import path from "node:path";
import { shouldBuildBundledCluster } from "./optional-bundled-clusters.mjs";
const TOP_LEVEL_PUBLIC_SURFACE_EXTENSIONS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
function readBundledPluginPackageJson(packageJsonPath) {
if (!fs.existsSync(packageJsonPath)) {
return null;
@@ -30,6 +32,39 @@ function collectPluginSourceEntries(packageJson) {
return packageEntries.length > 0 ? packageEntries : ["./index.ts"];
}
function collectTopLevelPublicSurfaceEntries(pluginDir) {
if (!fs.existsSync(pluginDir)) {
return [];
}
return fs
.readdirSync(pluginDir, { withFileTypes: true })
.flatMap((dirent) => {
if (!dirent.isFile()) {
return [];
}
const ext = path.extname(dirent.name);
if (!TOP_LEVEL_PUBLIC_SURFACE_EXTENSIONS.has(ext)) {
return [];
}
const normalizedName = dirent.name.toLowerCase();
if (
normalizedName.endsWith(".d.ts") ||
normalizedName.includes(".test.") ||
normalizedName.includes(".spec.") ||
normalizedName.includes(".fixture.") ||
normalizedName.includes(".snap")
) {
return [];
}
return [`./${dirent.name}`];
})
.toSorted((left, right) => left.localeCompare(right));
}
export function collectBundledPluginBuildEntries(params = {}) {
const cwd = params.cwd ?? process.cwd();
const env = params.env ?? process.env;
@@ -57,7 +92,12 @@ export function collectBundledPluginBuildEntries(params = {}) {
id: dirent.name,
hasPackageJson: packageJson !== null,
packageJson,
sourceEntries: collectPluginSourceEntries(packageJson),
sourceEntries: Array.from(
new Set([
...collectPluginSourceEntries(packageJson),
...collectTopLevelPublicSurfaceEntries(pluginDir),
]),
),
});
}
+2 -2
View File
@@ -170,7 +170,7 @@ export function collectPublishablePluginPackageErrors(
errors.push("package.json version must be non-empty.");
} else if (parseReleaseVersion(packageVersion) === null) {
errors.push(
`package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "${packageVersion}".`,
`package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "${packageVersion}".`,
);
}
if (!Array.isArray(extensions) || extensions.length === 0) {
@@ -224,7 +224,7 @@ export function collectPublishablePluginPackages(
const parsedVersion = parseReleaseVersion(version);
if (parsedVersion === null) {
validationErrors.push(
`${dir.name}: package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "${version}".`,
`${dir.name}: package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "${version}".`,
);
continue;
}
+14 -4
View File
@@ -5,6 +5,16 @@ ROOT=$(cd "$(dirname "$0")/.." && pwd)
ZIP=${1:?"Usage: $0 OpenClaw-<ver>.zip"}
FEED_URL=${2:-"https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml"}
PRIVATE_KEY_FILE=${SPARKLE_PRIVATE_KEY_FILE:-}
find_generate_appcast() {
if command -v generate_appcast >/dev/null 2>&1; then
command -v generate_appcast
return 0
fi
find "$ROOT/apps/macos/.build" -type f -path "*/artifacts/sparkle/Sparkle/bin/generate_appcast" -print -quit 2>/dev/null
}
if [[ -z "$PRIVATE_KEY_FILE" ]]; then
echo "Set SPARKLE_PRIVATE_KEY_FILE to your ed25519 private key (Sparkle)." >&2
exit 1
@@ -52,13 +62,13 @@ cp -f "$NOTES_HTML" "$TMP_DIR/${ZIP_BASE}.html"
DOWNLOAD_URL_PREFIX=${SPARKLE_DOWNLOAD_URL_PREFIX:-"https://github.com/openclaw/openclaw/releases/download/v${VERSION}/"}
export PATH="$ROOT/apps/macos/.build/artifacts/sparkle/Sparkle/bin:$PATH"
if ! command -v generate_appcast >/dev/null; then
echo "generate_appcast not found in PATH. Build Sparkle tools via SwiftPM." >&2
GENERATE_APPCAST="$(find_generate_appcast)"
if [[ -z "$GENERATE_APPCAST" ]]; then
echo "generate_appcast not found. Install Sparkle tooling or build the mac app first so SwiftPM emits the Sparkle binaries." >&2
exit 1
fi
generate_appcast \
"$GENERATE_APPCAST" \
--ed-key-file "$PRIVATE_KEY_FILE" \
--download-url-prefix "$DOWNLOAD_URL_PREFIX" \
--embed-release-notes \
+2
View File
@@ -16,6 +16,8 @@ release_channel="stable"
if [[ "${package_version}" == *-beta.* ]]; then
publish_cmd=(npm publish --access public --tag beta --provenance)
release_channel="beta"
elif [[ "${package_version}" == *-* ]]; then
publish_cmd=(npm publish --access public --tag latest --provenance)
fi
echo "Resolved package version: ${package_version}"
+35 -31
View File
@@ -18,17 +18,20 @@ type PackageJson = {
export type ParsedReleaseVersion = {
version: string;
baseVersion: string;
channel: "stable" | "beta";
year: number;
month: number;
day: number;
betaNumber?: number;
correctionNumber?: number;
date: Date;
};
export type ParsedReleaseTag = {
version: string;
packageVersion: string;
baseVersion: string;
channel: "stable" | "beta";
correctionNumber?: number;
date: Date;
@@ -37,7 +40,8 @@ export type ParsedReleaseTag = {
const STABLE_VERSION_REGEX = /^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)$/;
const BETA_VERSION_REGEX =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)-beta\.(?<beta>[1-9]\d*)$/;
const CORRECTION_TAG_REGEX = /^(?<base>\d{4}\.[1-9]\d?\.[1-9]\d?)-(?<correction>[1-9]\d*)$/;
const CORRECTION_VERSION_REGEX =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)-(?<correction>[1-9]\d*)$/;
const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw";
const MAX_CALVER_DISTANCE_DAYS = 2;
const REQUIRED_PACKED_PATHS = ["dist/control-ui/index.html"];
@@ -92,6 +96,7 @@ function parseDateParts(
return {
version,
baseVersion: `${year}.${month}.${day}`,
channel,
year,
month,
@@ -117,6 +122,20 @@ export function parseReleaseVersion(version: string): ParsedReleaseVersion | nul
return parseDateParts(trimmed, betaMatch.groups, "beta");
}
const correctionMatch = CORRECTION_VERSION_REGEX.exec(trimmed);
if (correctionMatch?.groups) {
const parsedCorrection = parseDateParts(trimmed, correctionMatch.groups, "stable");
const correctionNumber = Number.parseInt(correctionMatch.groups.correction ?? "", 10);
if (parsedCorrection === null || !Number.isInteger(correctionNumber) || correctionNumber < 1) {
return null;
}
return {
...parsedCorrection,
correctionNumber,
};
}
return null;
}
@@ -131,36 +150,14 @@ export function parseReleaseTagVersion(version: string): ParsedReleaseTag | null
return {
version: trimmed,
packageVersion: parsedVersion.version,
baseVersion: parsedVersion.baseVersion,
channel: parsedVersion.channel,
date: parsedVersion.date,
correctionNumber: undefined,
correctionNumber: parsedVersion.correctionNumber,
};
}
const correctionMatch = CORRECTION_TAG_REGEX.exec(trimmed);
if (!correctionMatch?.groups) {
return null;
}
const baseVersion = correctionMatch.groups.base ?? "";
const parsedBaseVersion = parseReleaseVersion(baseVersion);
const correctionNumber = Number.parseInt(correctionMatch.groups.correction ?? "", 10);
if (
parsedBaseVersion === null ||
parsedBaseVersion.channel !== "stable" ||
!Number.isInteger(correctionNumber) ||
correctionNumber < 1
) {
return null;
}
return {
version: trimmed,
packageVersion: parsedBaseVersion.version,
channel: "stable",
correctionNumber,
date: parsedBaseVersion.date,
};
return null;
}
function startOfUtcDay(date: Date): number {
@@ -227,7 +224,7 @@ export function collectReleaseTagErrors(params: {
const parsedVersion = parseReleaseVersion(packageVersion);
if (parsedVersion === null) {
errors.push(
`package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "${packageVersion || "<missing>"}".`,
`package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "${packageVersion || "<missing>"}".`,
);
}
@@ -244,17 +241,24 @@ export function collectReleaseTagErrors(params: {
}
const expectedTag = packageVersion ? `v${packageVersion}` : "<missing>";
const expectedCorrectionTag = parsedVersion?.channel === "stable" ? `${expectedTag}-N` : null;
const matchesExpectedTag =
parsedTag !== null &&
parsedVersion !== null &&
parsedTag.packageVersion === parsedVersion.version &&
parsedTag.channel === parsedVersion.channel;
parsedTag.channel === parsedVersion.channel &&
(parsedTag.packageVersion === parsedVersion.version ||
(parsedVersion.channel === "stable" &&
parsedVersion.correctionNumber === undefined &&
parsedTag.correctionNumber !== undefined &&
parsedTag.baseVersion === parsedVersion.baseVersion));
if (!matchesExpectedTag) {
errors.push(
`Release tag ${releaseTag || "<missing>"} does not match package.json version ${
packageVersion || "<missing>"
}; expected ${expectedCorrectionTag ? `${expectedTag} or ${expectedCorrectionTag}` : expectedTag}.`,
}; expected ${
parsedVersion?.channel === "stable" && parsedVersion.correctionNumber === undefined
? `${expectedTag} or ${expectedTag}-N`
: expectedTag
}.`,
);
}
+43
View File
@@ -12,14 +12,29 @@ ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_ROOT="$ROOT_DIR/apps/macos/.build"
PRODUCT="OpenClaw"
BUILD_CONFIG="${BUILD_CONFIG:-release}"
APP_VERSION_INPUT="${APP_VERSION:-$(cd "$ROOT_DIR" && node -p "require('./package.json').version" 2>/dev/null || echo "0.0.0")}"
# Default to universal binary for distribution builds (supports both Apple Silicon and Intel Macs)
export BUILD_ARCHS="${BUILD_ARCHS:-all}"
export BUILD_CONFIG
# Use release bundle ID (not .debug) so Sparkle auto-update works.
# The .debug suffix in package-mac-app.sh blanks SUFeedURL intentionally for dev builds.
export BUNDLE_ID="${BUNDLE_ID:-ai.openclaw.mac}"
canonical_sparkle_build() {
node --import tsx "$ROOT_DIR/scripts/sparkle-build.ts" canonical-build "$1"
}
# Local fallback releases must not silently fall back to a git-rev-count build number.
# For correction tags, pass a higher explicit APP_BUILD than the canonical floor.
if [[ -z "${APP_BUILD:-}" && "$BUILD_CONFIG" == "release" ]]; then
CANONICAL_APP_BUILD="$(canonical_sparkle_build "$APP_VERSION_INPUT" 2>/dev/null || true)"
if [[ "$CANONICAL_APP_BUILD" =~ ^[0-9]+$ ]]; then
export APP_BUILD="$CANONICAL_APP_BUILD"
fi
fi
"$ROOT_DIR/scripts/package-mac-app.sh"
APP="$ROOT_DIR/dist/OpenClaw.app"
@@ -29,6 +44,9 @@ if [[ ! -d "$APP" ]]; then
fi
VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$APP/Contents/Info.plist" 2>/dev/null || echo "0.0.0")
BUNDLE_VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "$APP/Contents/Info.plist" 2>/dev/null || echo "")
ACTUAL_BUNDLE_ID=$(/usr/libexec/PlistBuddy -c "Print CFBundleIdentifier" "$APP/Contents/Info.plist" 2>/dev/null || echo "")
ACTUAL_FEED_URL=$(/usr/libexec/PlistBuddy -c "Print SUFeedURL" "$APP/Contents/Info.plist" 2>/dev/null || echo "")
ZIP="$ROOT_DIR/dist/OpenClaw-$VERSION.zip"
DMG="$ROOT_DIR/dist/OpenClaw-$VERSION.dmg"
NOTARY_ZIP="$ROOT_DIR/dist/OpenClaw-$VERSION.notary.zip"
@@ -42,6 +60,31 @@ if [[ "$SKIP_NOTARIZE" == "1" ]]; then
NOTARIZE=0
fi
if [[ "$BUILD_CONFIG" == "release" ]]; then
if [[ "$ACTUAL_BUNDLE_ID" == *.debug ]]; then
echo "Error: release packaging produced debug bundle id '$ACTUAL_BUNDLE_ID'." >&2
exit 1
fi
if [[ -z "$ACTUAL_FEED_URL" ]]; then
echo "Error: release packaging produced an empty SUFeedURL." >&2
exit 1
fi
CANONICAL_APP_BUILD="$(canonical_sparkle_build "$VERSION" 2>/dev/null || true)"
if [[ "$CANONICAL_APP_BUILD" =~ ^[0-9]+$ ]]; then
if [[ ! "$BUNDLE_VERSION" =~ ^[0-9]+$ ]]; then
echo "Error: release packaging produced non-numeric CFBundleVersion '$BUNDLE_VERSION'." >&2
exit 1
fi
if (( BUNDLE_VERSION < CANONICAL_APP_BUILD )); then
echo "Error: CFBundleVersion '$BUNDLE_VERSION' is below the canonical Sparkle floor '$CANONICAL_APP_BUILD' for '$VERSION'." >&2
echo "Set APP_BUILD explicitly only when you need a higher correction build." >&2
exit 1
fi
fi
fi
if [[ "$NOTARIZE" == "1" ]]; then
echo "📦 Notary zip: $NOTARY_ZIP"
rm -f "$NOTARY_ZIP"
+2
View File
@@ -23,6 +23,8 @@ release_channel="stable"
if [[ "${package_version}" == *-beta.* ]]; then
publish_cmd=(npm publish --access public --tag beta --provenance)
release_channel="beta"
elif [[ "${package_version}" == *-* ]]; then
publish_cmd=(npm publish --access public --tag latest --provenance)
fi
echo "Resolved package dir: ${package_dir}"
+12
View File
@@ -329,6 +329,18 @@ async function main() {
for (const path of missing) {
console.error(` - ${path}`);
}
if (
missing.some(
(path) =>
path === "dist/build-info.json" ||
path === "dist/control-ui/index.html" ||
path.startsWith("dist/"),
)
) {
console.error(
"release-check: build artifacts are missing. Run `pnpm build` before `pnpm release:check`.",
);
}
}
if (forbidden.length > 0) {
console.error("release-check: forbidden files in npm pack:");
+14
View File
@@ -22,6 +22,20 @@ export function hasFatalTestRunOutput(output) {
return fatalOutputPatterns.some((pattern) => pattern.test(output));
}
export function formatCapturedOutputTail(output, maxLines = 60, maxChars = 6000) {
const trimmed = output.trim();
if (!trimmed) {
return "";
}
const lines = trimmed.split(/\r?\n/u);
const tailLines = lines.slice(-maxLines);
const tail = tailLines.join("\n");
if (tail.length <= maxChars) {
return tail;
}
return tail.slice(-maxChars);
}
export function resolveTestRunExitCode({ code, signal, output, fatalSeen = false, childError }) {
if (typeof code === "number" && code !== 0) {
return code;
+114 -1
View File
@@ -11,6 +11,7 @@ import {
} from "./test-parallel-memory.mjs";
import {
appendCapturedOutput,
formatCapturedOutputTail,
hasFatalTestRunOutput,
resolveTestRunExitCode,
} from "./test-parallel-utils.mjs";
@@ -41,6 +42,13 @@ const writeTempJsonArtifact = (name, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8");
return filePath;
};
const sanitizeArtifactName = (value) => {
const normalized = value
.trim()
.replace(/[^a-z0-9._-]+/giu, "-")
.replace(/^-+|-+$/gu, "");
return normalized || "artifact";
};
const cleanupTempArtifacts = () => {
if (tempArtifactDir === null) {
return;
@@ -807,6 +815,53 @@ const targetedEntries = (() => {
return [createTargetedEntry(owner, false, uniqueFilters)];
}).flat();
})();
const estimateTopLevelEntryDurationMs = (entry) => {
const filters = getExplicitEntryFilters(entry.args);
if (filters.length === 0) {
return unitTimingManifest.defaultDurationMs;
}
return filters.reduce((totalMs, file) => {
if (isUnitConfigTestFile(file)) {
return totalMs + estimateUnitDurationMs(file);
}
if (channelTestPrefixes.some((prefix) => file.startsWith(prefix))) {
return totalMs + 3_000;
}
if (file.startsWith("extensions/")) {
return totalMs + 2_000;
}
return totalMs + 1_000;
}, 0);
};
const topLevelSingleShardAssignments = (() => {
if (shardIndexOverride === null || shardCount <= 1) {
return new Map();
}
// Single-file and other non-shardable explicit lanes would otherwise run on
// every shard. Assign them to one top-level shard instead.
const entriesNeedingAssignment = runs.filter((entry) => {
const explicitFilterCount = countExplicitEntryFilters(entry.args);
if (explicitFilterCount === null) {
return false;
}
const effectiveShardCount = Math.min(shardCount, Math.max(1, explicitFilterCount - 1));
return effectiveShardCount <= 1;
});
const assignmentMap = new Map();
const buckets = packFilesByDuration(
entriesNeedingAssignment,
shardCount,
estimateTopLevelEntryDurationMs,
);
for (const [bucketIndex, bucket] of buckets.entries()) {
for (const entry of bucket) {
assignmentMap.set(entry, bucketIndex + 1);
}
}
return assignmentMap;
})();
// Node 25 local runs still show cross-process worker shutdown contention even
// after moving the known heavy files into singleton lanes.
const topLevelParallelEnabled =
@@ -1007,6 +1062,13 @@ const heapSnapshotBaseDir = heapSnapshotEnabled
const ensureNodeOptionFlag = (nodeOptions, flagPrefix, nextValue) =>
nodeOptions.includes(flagPrefix) ? nodeOptions : `${nodeOptions} ${nextValue}`.trim();
const isNodeLikeProcess = (command) => /(?:^|\/)node(?:$|\.exe$)/iu.test(command);
const getShardLabel = (args) => {
const shardIndex = args.findIndex((arg) => arg === "--shard");
if (shardIndex < 0) {
return "";
}
return typeof args[shardIndex + 1] === "string" ? args[shardIndex + 1] : "";
};
const runOnce = (entry, extraArgs = []) =>
new Promise((resolve) => {
@@ -1024,6 +1086,19 @@ const runOnce = (entry, extraArgs = []) =>
...extraArgs,
]
: [...entryArgs, ...silentArgs, ...windowsCiArgs, ...extraArgs];
const shardLabel = getShardLabel(extraArgs);
const artifactStem = [
sanitizeArtifactName(entry.name),
shardLabel ? `shard-${sanitizeArtifactName(shardLabel)}` : "",
String(startedAt),
]
.filter(Boolean)
.join("-");
const laneLogPath = path.join(ensureTempArtifactDir(), `${artifactStem}.log`);
const laneLogStream = fs.createWriteStream(laneLogPath, { flags: "w" });
laneLogStream.write(`[test-parallel] entry=${entry.name}\n`);
laneLogStream.write(`[test-parallel] cwd=${process.cwd()}\n`);
laneLogStream.write(`[test-parallel] command=${[pnpm, ...args].join(" ")}\n\n`);
console.log(
`[test-parallel] start ${entry.name} workers=${maxWorkers ?? "default"} filters=${String(
countExplicitEntryFilters(entryArgs) ?? "all",
@@ -1216,6 +1291,7 @@ const runOnce = (entry, extraArgs = []) =>
}, heapSnapshotIntervalMs);
}
} catch (err) {
laneLogStream.end();
console.error(`[test-parallel] spawn failed: ${String(err)}`);
resolve(1);
return;
@@ -1225,6 +1301,7 @@ const runOnce = (entry, extraArgs = []) =>
const text = chunk.toString();
fatalSeen ||= hasFatalTestRunOutput(`${output}${text}`);
output = appendCapturedOutput(output, text);
laneLogStream.write(text);
logMemoryTraceForText(text);
process.stdout.write(chunk);
});
@@ -1232,11 +1309,13 @@ const runOnce = (entry, extraArgs = []) =>
const text = chunk.toString();
fatalSeen ||= hasFatalTestRunOutput(`${output}${text}`);
output = appendCapturedOutput(output, text);
laneLogStream.write(text);
logMemoryTraceForText(text);
process.stderr.write(chunk);
});
child.on("error", (err) => {
childError = err;
laneLogStream.write(`\n[test-parallel] child error: ${String(err)}\n`);
console.error(`[test-parallel] child error: ${String(err)}`);
});
child.on("close", (code, signal) => {
@@ -1248,9 +1327,36 @@ const runOnce = (entry, extraArgs = []) =>
}
children.delete(child);
const resolvedCode = resolveTestRunExitCode({ code, signal, output, fatalSeen, childError });
const elapsedMs = Date.now() - startedAt;
logMemoryTraceSummary();
if (resolvedCode !== 0) {
const failureTail = formatCapturedOutputTail(output);
const failureArtifactPath = writeTempJsonArtifact(`${artifactStem}-failure`, {
entry: entry.name,
command: [pnpm, ...args],
elapsedMs,
error: childError ? String(childError) : null,
exitCode: resolvedCode,
fatalSeen,
logPath: laneLogPath,
outputTail: failureTail,
signal: signal ?? null,
});
if (failureTail) {
console.error(`[test-parallel] failure tail ${entry.name}\n${failureTail}`);
}
console.error(
`[test-parallel] failure artifacts ${entry.name} log=${laneLogPath} meta=${failureArtifactPath}`,
);
}
laneLogStream.write(
`\n[test-parallel] done ${entry.name} code=${String(resolvedCode)} signal=${
signal ?? "none"
} elapsed=${formatElapsedMs(elapsedMs)}\n`,
);
laneLogStream.end();
console.log(
`[test-parallel] done ${entry.name} code=${String(resolvedCode)} elapsed=${formatElapsedMs(Date.now() - startedAt)}`,
`[test-parallel] done ${entry.name} code=${String(resolvedCode)} elapsed=${formatElapsedMs(elapsedMs)}`,
);
resolve(resolvedCode);
});
@@ -1258,6 +1364,13 @@ const runOnce = (entry, extraArgs = []) =>
const run = async (entry, extraArgs = []) => {
const explicitFilterCount = countExplicitEntryFilters(entry.args);
const topLevelAssignedShard = topLevelSingleShardAssignments.get(entry);
if (topLevelAssignedShard !== undefined) {
if (shardIndexOverride !== null && shardIndexOverride !== topLevelAssignedShard) {
return 0;
}
return runOnce(entry, extraArgs);
}
// Vitest requires the shard count to stay strictly below the number of
// resolved test files, so explicit-filter lanes need a `< fileCount` cap.
const effectiveShardCount =
+15 -3
View File
@@ -6,6 +6,7 @@ import {
buildEnforcedShellCommand,
evaluateShellAllowlist,
recordAllowlistUse,
resolveApprovalAuditCandidatePath,
requiresExecApproval,
resolveAllowAlwaysPatterns,
} from "../infra/exec-approvals.js";
@@ -184,7 +185,10 @@ export async function processGatewayAllowlist(
agentId: params.agentId,
sessionKey: params.sessionKey,
}),
resolvedPath: allowlistEval.segments[0]?.resolution?.resolvedPath,
resolvedPath: resolveApprovalAuditCandidatePath(
allowlistEval.segments[0]?.resolution ?? null,
params.workdir,
),
...buildExecApprovalTurnSourceContext(params),
});
const {
@@ -200,7 +204,10 @@ export async function processGatewayAllowlist(
...requestArgs,
register: registerGatewayApproval,
});
const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath;
const resolvedPath = resolveApprovalAuditCandidatePath(
allowlistEval.segments[0]?.resolution ?? null,
params.workdir,
);
const effectiveTimeout =
typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec;
const followupTarget = buildExecApprovalFollowupTarget({
@@ -337,7 +344,12 @@ export async function processGatewayAllowlist(
throw new Error("exec denied: allowlist miss");
}
recordMatchedAllowlistUse(allowlistEval.segments[0]?.resolution?.resolvedPath);
recordMatchedAllowlistUse(
resolveApprovalAuditCandidatePath(
allowlistEval.segments[0]?.resolution ?? null,
params.workdir,
),
);
return { execCommandOverride: enforcedCommand };
}
+20 -8
View File
@@ -13,6 +13,20 @@ const LIVE = isLiveTestEnabled(["MINIMAX_LIVE_TEST"]);
const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip;
async function runMinimaxTextProbe(model: Model<"anthropic-messages">, maxTokens: number) {
const res = await completeSimple(
model,
{
messages: createSingleUserPromptMessage(),
},
{ apiKey: MINIMAX_KEY, maxTokens },
);
return {
res,
text: extractNonEmptyAssistantText(res.content),
};
}
describeLive("minimax live", () => {
it("returns assistant text", async () => {
const model: Model<"anthropic-messages"> = {
@@ -28,14 +42,12 @@ describeLive("minimax live", () => {
contextWindow: 200000,
maxTokens: 8192,
};
const res = await completeSimple(
model,
{
messages: createSingleUserPromptMessage(),
},
{ apiKey: MINIMAX_KEY, maxTokens: 64 },
);
const text = extractNonEmptyAssistantText(res.content);
let { res, text } = await runMinimaxTextProbe(model, 128);
// MiniMax can spend a small token budget in hidden thinking before it emits
// the visible answer. Give this smoke probe one larger retry.
if (text.length === 0 && res.stopReason === "length") {
({ text } = await runMinimaxTextProbe(model, 256));
}
expect(text.length).toBeGreaterThan(0);
}, 20000);
});
+17 -1
View File
@@ -13,6 +13,16 @@ const LIVE = isLiveTestEnabled(["MOONSHOT_LIVE_TEST"]);
const describeLive = LIVE && MOONSHOT_KEY ? describe : describe.skip;
function forceMoonshotInstantMode(payload: unknown): void {
if (!payload || typeof payload !== "object") {
return;
}
// Moonshot's official API exposes instant mode via thinking.type=disabled.
// Without this, tiny smoke probes can spend the full token budget in hidden
// reasoning_content and never emit visible assistant text.
(payload as Record<string, unknown>).thinking = { type: "disabled" };
}
describeLive("moonshot live", () => {
it("returns assistant text", async () => {
const model: Model<"openai-completions"> = {
@@ -33,7 +43,13 @@ describeLive("moonshot live", () => {
{
messages: createSingleUserPromptMessage(),
},
{ apiKey: MOONSHOT_KEY, maxTokens: 64 },
{
apiKey: MOONSHOT_KEY,
maxTokens: 64,
onPayload: (payload) => {
forceMoonshotInstantMode(payload);
},
},
);
const text = extractNonEmptyAssistantText(res.content);
@@ -143,41 +143,12 @@ describeAnthropicLive("pi embedded extra params (anthropic live)", () => {
});
describeGeminiLive("pi embedded extra params (gemini live)", () => {
function isGoogleModelUnavailableError(raw: string | undefined): boolean {
const msg = (raw ?? "").toLowerCase();
if (!msg) {
return false;
}
return (
msg.includes("not found") ||
msg.includes("404") ||
msg.includes("not_available") ||
msg.includes("permission denied") ||
msg.includes("unsupported model")
);
}
function isGoogleImageProcessingError(raw: string | undefined): boolean {
const msg = (raw ?? "").toLowerCase();
if (!msg) {
return false;
}
return (
msg.includes("unable to process input image") ||
msg.includes("invalid_argument") ||
msg.includes("bad request")
);
}
async function runGeminiProbe(params: {
agentStreamFn: typeof streamSimple;
function buildGeminiPayloadThroughWrapper(params: {
model: Model<"google-generative-ai">;
apiKey: string;
oneByOneRedPngBase64: string;
includeImage?: boolean;
prompt: string;
onPayload?: (payload: Record<string, unknown>) => void;
}): Promise<{ sawDone: boolean; stopReason?: string; errorMessage?: string }> {
}): Record<string, unknown> {
const userContent: Array<
{ type: "text"; text: string } | { type: "image"; mimeType: string; data: string }
> = [{ type: "text", text: params.prompt }];
@@ -189,64 +160,70 @@ describeGeminiLive("pi embedded extra params (gemini live)", () => {
});
}
const stream = params.agentStreamFn(
params.model,
{
messages: [
{
role: "user",
content: userContent,
timestamp: Date.now(),
},
],
},
{
apiKey: params.apiKey,
reasoning: "high",
maxTokens: 64,
onPayload: (payload) => {
params.onPayload?.(payload as Record<string, unknown>);
const payload: Record<string, unknown> = {
model: params.model.id,
contents: [{ role: "user", parts: userContent.map(mapGeminiContentPart) }],
config: {
maxOutputTokens: 64,
thinkingConfig: {
includeThoughts: true,
thinkingBudget: 32768,
},
},
};
const baseStreamFn = (
_model: Model<"google-generative-ai">,
_context: unknown,
options?: {
onPayload?: (payload: unknown) => unknown;
},
) => {
options?.onPayload?.(payload);
return {} as ReturnType<typeof streamSimple>;
};
const agent = { streamFn: baseStreamFn as typeof streamSimple };
applyExtraParamsToAgent(agent, undefined, "google", params.model.id, undefined, "high");
void agent.streamFn(
params.model,
{ messages: [] },
{
reasoning: "high",
maxTokens: 64,
},
);
let sawDone = false;
let stopReason: string | undefined;
let errorMessage: string | undefined;
for await (const event of stream) {
if (event.type === "done") {
sawDone = true;
stopReason = event.reason;
} else if (event.type === "error") {
stopReason = event.reason;
errorMessage = event.error?.errorMessage;
}
}
return { sawDone, stopReason, errorMessage };
return payload;
}
it("sanitizes Gemini 3.1 thinking payload and keeps image parts with reasoning enabled", async () => {
const model = getModel("google", "gemini-2.5-pro") as unknown as Model<"google-generative-ai">;
function mapGeminiContentPart(
part: { type: "text"; text: string } | { type: "image"; mimeType: string; data: string },
): { text: string } | { inlineData: { mimeType: string; data: string } } {
if (part.type === "text") {
return { text: part.text };
}
return {
inlineData: {
mimeType: part.mimeType,
data: part.data,
},
};
}
const agent = { streamFn: streamSimple };
applyExtraParamsToAgent(agent, undefined, "google", model.id, undefined, "high");
// Payload mutation is covered by extra-params.google.test.ts, and Gemini
// roundtrips are exercised by the dedicated live gateway/model suites. This
// direct live test currently flakes on Vitest timeout teardown without
// providing unique signal.
it.skip("sanitizes Gemini thinking payload and keeps image parts with reasoning enabled", async () => {
const model = getModel("google", "gemini-2.5-pro") as unknown as Model<"google-generative-ai">;
const oneByOneRedPngBase64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGP4zwAAAgIBAJBzWgkAAAAASUVORK5CYII=";
let capturedPayload: Record<string, unknown> | undefined;
const imageResult = await runGeminiProbe({
agentStreamFn: agent.streamFn,
const capturedPayload = buildGeminiPayloadThroughWrapper({
model,
apiKey: GEMINI_KEY,
oneByOneRedPngBase64,
includeImage: true,
prompt: "What color is this image? Reply with one word.",
onPayload: (payload) => {
capturedPayload = payload;
},
});
expect(capturedPayload).toBeDefined();
@@ -258,7 +235,9 @@ describeGeminiLive("pi embedded extra params (gemini live)", () => {
expect(typeof thinkingBudget).toBe("number");
expect(thinkingBudget).toBeGreaterThanOrEqual(0);
}
expect(thinkingConfig?.thinkingLevel).toBe("HIGH");
// Gemini 3.1-specific thinkingLevel fill is covered by
// extra-params.google.test.ts. The live probe uses the stable 2.5 model and
// only verifies that we never forward an invalid negative budget.
const imagePart = (
capturedPayload?.contents as
@@ -270,42 +249,7 @@ describeGeminiLive("pi embedded extra params (gemini live)", () => {
data: oneByOneRedPngBase64,
});
if (!imageResult.sawDone && !isGoogleModelUnavailableError(imageResult.errorMessage)) {
expect(isGoogleImageProcessingError(imageResult.errorMessage)).toBe(true);
}
const textResult = await runGeminiProbe({
agentStreamFn: agent.streamFn,
model,
apiKey: GEMINI_KEY,
oneByOneRedPngBase64,
includeImage: false,
prompt: "Reply with exactly OK.",
});
if (!textResult.sawDone && isGoogleModelUnavailableError(textResult.errorMessage)) {
// Some keys/regions do not expose Gemini 3.1 preview. Fall back to a
// stable model to keep live reasoning verification active.
const fallbackModel = getModel(
"google",
"gemini-2.5-pro",
) as unknown as Model<"google-generative-ai">;
const fallback = await runGeminiProbe({
agentStreamFn: agent.streamFn,
model: fallbackModel,
apiKey: GEMINI_KEY,
oneByOneRedPngBase64,
includeImage: false,
prompt: "Reply with exactly OK.",
});
expect(fallback.sawDone).toBe(true);
expect(fallback.stopReason).toBeDefined();
expect(fallback.stopReason).not.toBe("error");
return;
}
expect(textResult.sawDone).toBe(true);
expect(textResult.stopReason).toBeDefined();
expect(textResult.stopReason).not.toBe("error");
}, 45_000);
// End-to-end Gemini roundtrips are already covered elsewhere. This live
// check stays focused on the request payload we generate for those suites.
}, 60_000);
});
@@ -308,6 +308,34 @@ describe("sanitizeSessionHistory", () => {
).toBe(false);
});
it("canonicalizes malformed assistant history content before replay sanitization", async () => {
setNonGoogleModelApi();
const messages = castAgentMessages([
{ role: "user", content: "Question" },
{ role: "assistant", content: "legacy-content" },
{ role: "assistant", content: { unexpected: true } },
]);
const result = await sanitizeSessionHistory({
messages,
modelApi: "openai-responses",
provider: "openai",
sessionManager: mockSessionManager,
sessionId: TEST_SESSION_ID,
});
expect(result[0]).toEqual(messages[0]);
expect(result[1]).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "legacy-content" }],
});
expect(result[2]).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "" }],
});
});
it("annotates inter-session user messages before context sanitization", async () => {
setNonGoogleModelApi();
+62 -1
View File
@@ -60,6 +60,8 @@ const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
]);
const INTER_SESSION_PREFIX_BASE = "[Inter-session message]";
type AssistantHistoryMessage = Extract<AgentMessage, { role: "assistant" }>;
type RawAssistantHistoryMessage = Omit<AssistantHistoryMessage, "content"> & { content?: unknown };
function buildInterSessionPrefix(message: AgentMessage): string {
const provenance = normalizeInputProvenance((message as { provenance?: unknown }).provenance);
@@ -140,6 +142,61 @@ function annotateInterSessionUserMessages(messages: AgentMessage[]): AgentMessag
return touched ? out : messages;
}
function describeAssistantContentKind(content: unknown): string {
if (Array.isArray(content)) {
return "array";
}
if (content === null) {
return "null";
}
return typeof content;
}
function canonicalizeAssistantHistoryMessages(params: {
messages: AgentMessage[];
sessionId: string;
}): AgentMessage[] {
let touched = false;
let repairedCount = 0;
const repairedKinds = new Set<string>();
const out: AgentMessage[] = [];
for (const msg of params.messages) {
if (!msg || typeof msg !== "object" || msg.role !== "assistant") {
out.push(msg);
continue;
}
const assistant = msg as RawAssistantHistoryMessage;
if (Array.isArray(assistant.content)) {
out.push(msg);
continue;
}
// Session transcripts and custom stream boundaries have historically leaked
// malformed assistant payloads. Repair them here so Pi replay only sees the
// canonical array-based assistant content contract.
const repairedText = typeof assistant.content === "string" ? assistant.content : "";
out.push({
...(assistant as unknown as Record<string, unknown>),
content: [{ type: "text", text: repairedText }],
} as AgentMessage);
touched = true;
repairedCount += 1;
repairedKinds.add(describeAssistantContentKind(assistant.content));
}
if (!touched) {
return params.messages;
}
log.warn(
`sanitizeSessionHistory: canonicalized ${repairedCount} malformed assistant message(s) before replay ` +
`session=${params.sessionId} contentKinds=${Array.from(repairedKinds).join(",")}`,
);
return out;
}
function parseMessageTimestamp(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
@@ -537,8 +594,12 @@ export async function sanitizeSessionHistory(params: {
modelId: params.modelId,
});
const withInterSessionMarkers = annotateInterSessionUserMessages(params.messages);
const canonicalizedAssistantHistory = canonicalizeAssistantHistoryMessages({
messages: withInterSessionMarkers,
sessionId: params.sessionId,
});
const sanitizedImages = await sanitizeSessionMessagesImages(
withInterSessionMarkers,
canonicalizedAssistantHistory,
"session:history",
{
sanitizeMode: policy.sanitizeMode,
+21 -1
View File
@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock getProcessStartTime so PID-recycling detection works on non-Linux
// (macOS, CI runners). isPidAlive is left unmocked.
@@ -18,6 +18,7 @@ import {
__testing,
acquireSessionWriteLock,
cleanStaleLockFiles,
resetSessionWriteLockStateForTest,
resolveSessionLockMaxHoldFromTimeout,
} from "./session-write-lock.js";
@@ -95,6 +96,11 @@ async function expectActiveInProcessLockIsNotReclaimed(params?: {
}
describe("acquireSessionWriteLock", () => {
afterEach(() => {
resetSessionWriteLockStateForTest();
vi.restoreAllMocks();
});
it("reuses locks across symlinked session paths", async () => {
if (process.platform === "win32") {
return;
@@ -221,6 +227,17 @@ describe("acquireSessionWriteLock", () => {
}
});
it("removes lock files during process-exit cleanup", async () => {
await withTempSessionLockFile(async ({ sessionFile, lockPath }) => {
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
__testing.releaseAllLocksSync();
await expect(fs.access(lockPath)).rejects.toThrow();
await lock.release();
});
});
it("derives max hold from timeout plus grace", () => {
expect(resolveSessionLockMaxHoldFromTimeout({ timeoutMs: 600_000 })).toBe(720_000);
expect(resolveSessionLockMaxHoldFromTimeout({ timeoutMs: 1_000, minMs: 5_000 })).toBe(121_000);
@@ -324,6 +341,9 @@ describe("acquireSessionWriteLock", () => {
});
it("reclaims lock files with recycled PIDs", async () => {
if (process.platform !== "linux") {
return;
}
await withTempSessionLockFile(async ({ sessionFile, lockPath }) => {
// Write a lock with a live PID (current process) but a wrong starttime,
// simulating PID recycling: the PID is alive but belongs to a different
-7
View File
@@ -177,13 +177,6 @@ async function releaseHeldLock(
*/
function releaseAllLocksSync(): void {
for (const [sessionFile, held] of HELD_LOCKS) {
try {
if (typeof held.handle.close === "function") {
void held.handle.close().catch(() => {});
}
} catch {
// Ignore errors during cleanup - best effort
}
try {
fsSync.rmSync(held.lockPath, { force: true });
} catch {
+233 -1
View File
@@ -1,3 +1,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const fetchClawHubSkillDetailMock = vi.fn();
@@ -29,7 +32,8 @@ vi.mock("../infra/archive.js", () => ({
fileExists: fileExistsMock,
}));
const { installSkillFromClawHub, searchSkillsFromClawHub } = await import("./skills-clawhub.js");
const { installSkillFromClawHub, searchSkillsFromClawHub, updateSkillsFromClawHub } =
await import("./skills-clawhub.js");
describe("skills-clawhub", () => {
beforeEach(() => {
@@ -95,6 +99,234 @@ describe("skills-clawhub", () => {
});
});
describe("legacy tracked slugs remain updatable", () => {
async function createLegacyTrackedSkillFixture(slug: string) {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-clawhub-"));
const skillDir = path.join(workspaceDir, "skills", slug);
await fs.mkdir(path.join(skillDir, ".clawhub"), { recursive: true });
await fs.mkdir(path.join(workspaceDir, ".clawhub"), { recursive: true });
await fs.writeFile(
path.join(skillDir, ".clawhub", "origin.json"),
`${JSON.stringify(
{
version: 1,
registry: "https://legacy.clawhub.ai",
slug,
installedVersion: "0.9.0",
installedAt: 123,
},
null,
2,
)}\n`,
"utf8",
);
await fs.writeFile(
path.join(workspaceDir, ".clawhub", "lock.json"),
`${JSON.stringify(
{
version: 1,
skills: {
[slug]: {
version: "0.9.0",
installedAt: 123,
},
},
},
null,
2,
)}\n`,
"utf8",
);
return { workspaceDir, skillDir };
}
it("updates all tracked legacy Unicode slugs in place", async () => {
const slug = "re\u0430ct";
const { workspaceDir } = await createLegacyTrackedSkillFixture(slug);
installPackageDirMock.mockResolvedValueOnce({
ok: true,
targetDir: path.join(workspaceDir, "skills", slug),
});
try {
const results = await updateSkillsFromClawHub({
workspaceDir,
});
expect(fetchClawHubSkillDetailMock).toHaveBeenCalledWith({
slug,
baseUrl: "https://legacy.clawhub.ai",
});
expect(downloadClawHubSkillArchiveMock).toHaveBeenCalledWith({
slug,
version: "1.0.0",
baseUrl: "https://legacy.clawhub.ai",
});
expect(results).toMatchObject([
{
ok: true,
slug,
previousVersion: "0.9.0",
version: "1.0.0",
targetDir: path.join(workspaceDir, "skills", slug),
},
]);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("updates a legacy Unicode slug when requested explicitly", async () => {
const slug = "re\u0430ct";
const { workspaceDir } = await createLegacyTrackedSkillFixture(slug);
installPackageDirMock.mockResolvedValueOnce({
ok: true,
targetDir: path.join(workspaceDir, "skills", slug),
});
try {
const results = await updateSkillsFromClawHub({
workspaceDir,
slug,
});
expect(results).toMatchObject([
{
ok: true,
slug,
previousVersion: "0.9.0",
version: "1.0.0",
targetDir: path.join(workspaceDir, "skills", slug),
},
]);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("still rejects an untracked Unicode slug passed to update", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-clawhub-"));
try {
await expect(
updateSkillsFromClawHub({
workspaceDir,
slug: "re\u0430ct",
}),
).rejects.toThrow("Invalid skill slug");
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
});
describe("normalizeSlug rejects non-ASCII homograph slugs", () => {
it("rejects Cyrillic homograph 'а' (U+0430) in slug", async () => {
const result = await installSkillFromClawHub({
workspaceDir: "/tmp/workspace",
slug: "re\u0430ct",
});
expect(result).toMatchObject({
ok: false,
error: expect.stringContaining("Invalid skill slug"),
});
});
it("rejects Cyrillic homograph 'е' (U+0435) in slug", async () => {
const result = await installSkillFromClawHub({
workspaceDir: "/tmp/workspace",
slug: "r\u0435act",
});
expect(result).toMatchObject({
ok: false,
error: expect.stringContaining("Invalid skill slug"),
});
});
it("rejects Cyrillic homograph 'о' (U+043E) in slug", async () => {
const result = await installSkillFromClawHub({
workspaceDir: "/tmp/workspace",
slug: "t\u043Edo",
});
expect(result).toMatchObject({
ok: false,
error: expect.stringContaining("Invalid skill slug"),
});
});
it("rejects slug with mixed Unicode and ASCII", async () => {
const result = await installSkillFromClawHub({
workspaceDir: "/tmp/workspace",
slug: "cаlеndаr",
});
expect(result).toMatchObject({
ok: false,
error: expect.stringContaining("Invalid skill slug"),
});
});
it("rejects slug with non-Latin scripts", async () => {
const result = await installSkillFromClawHub({
workspaceDir: "/tmp/workspace",
slug: "技能",
});
expect(result).toMatchObject({
ok: false,
error: expect.stringContaining("Invalid skill slug"),
});
});
it("rejects Unicode that case-folds to ASCII (Kelvin sign U+212A)", async () => {
// "\u212A" (Kelvin sign) lowercases to "k" — must be caught before lowercasing
const result = await installSkillFromClawHub({
workspaceDir: "/tmp/workspace",
slug: "\u212Aalendar",
});
expect(result).toMatchObject({
ok: false,
error: expect.stringContaining("Invalid skill slug"),
});
});
it("rejects slug starting with a hyphen", async () => {
const result = await installSkillFromClawHub({
workspaceDir: "/tmp/workspace",
slug: "-calendar",
});
expect(result).toMatchObject({
ok: false,
error: expect.stringContaining("Invalid skill slug"),
});
});
it("rejects slug ending with a hyphen", async () => {
const result = await installSkillFromClawHub({
workspaceDir: "/tmp/workspace",
slug: "calendar-",
});
expect(result).toMatchObject({
ok: false,
error: expect.stringContaining("Invalid skill slug"),
});
});
it("accepts uppercase ASCII slugs (preserves original casing behavior)", async () => {
const result = await installSkillFromClawHub({
workspaceDir: "/tmp/workspace",
slug: "React",
});
expect(result).toMatchObject({ ok: true });
});
it("accepts valid lowercase ASCII slugs", async () => {
const result = await installSkillFromClawHub({
workspaceDir: "/tmp/workspace",
slug: "calendar-2",
});
expect(result).toMatchObject({ ok: true });
});
});
it("uses search for browse-all skill discovery", async () => {
searchClawHubSkillsMock.mockResolvedValueOnce([
{
+152 -34
View File
@@ -62,14 +62,62 @@ type Logger = {
info?: (message: string) => void;
};
function normalizeSlug(raw: string): string {
const trimmed = raw.trim();
if (!trimmed || trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("..")) {
const VALID_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
// eslint-disable-next-line no-control-regex -- detects any character outside printable ASCII
const NON_ASCII_PATTERN = /[^\x00-\x7F]/;
function normalizeTrackedSlug(raw: string): string {
const slug = raw.trim();
if (!slug || slug.includes("/") || slug.includes("\\") || slug.includes("..")) {
throw new Error(`Invalid skill slug: ${raw}`);
}
return trimmed;
return slug;
}
function validateRequestedSlug(raw: string): string {
const slug = normalizeTrackedSlug(raw);
if (NON_ASCII_PATTERN.test(slug) || !VALID_SLUG_PATTERN.test(slug)) {
throw new Error(`Invalid skill slug: ${raw}`);
}
return slug;
}
async function resolveRequestedUpdateSlug(params: {
workspaceDir: string;
requestedSlug: string;
lock: ClawHubSkillsLockfile;
}): Promise<string> {
const trackedSlug = normalizeTrackedSlug(params.requestedSlug);
const trackedTargetDir = resolveSkillInstallDir(params.workspaceDir, trackedSlug);
const trackedOrigin = await readClawHubSkillOrigin(trackedTargetDir);
if (trackedOrigin || params.lock.skills[trackedSlug]) {
return trackedSlug;
}
return validateRequestedSlug(params.requestedSlug);
}
type ClawHubInstallParams = {
workspaceDir: string;
slug: string;
version?: string;
baseUrl?: string;
force?: boolean;
logger?: Logger;
};
type TrackedUpdateTarget =
| {
ok: true;
slug: string;
baseUrl?: string;
previousVersion: string | null;
}
| {
ok: false;
slug: string;
error: string;
};
function resolveSkillInstallDir(workspaceDir: string, slug: string): string {
const skillsDir = path.join(path.resolve(workspaceDir), "skills");
const target = resolveSafeInstallDir({
@@ -218,22 +266,16 @@ async function installExtractedSkill(params: {
return { ok: true, targetDir };
}
export async function installSkillFromClawHub(params: {
workspaceDir: string;
slug: string;
version?: string;
baseUrl?: string;
force?: boolean;
logger?: Logger;
}): Promise<InstallClawHubSkillResult> {
async function performClawHubSkillInstall(
params: ClawHubInstallParams,
): Promise<InstallClawHubSkillResult> {
try {
const slug = normalizeSlug(params.slug);
const { detail, version } = await resolveInstallVersion({
slug,
slug: params.slug,
version: params.version,
baseUrl: params.baseUrl,
});
const targetDir = resolveSkillInstallDir(params.workspaceDir, slug);
const targetDir = resolveSkillInstallDir(params.workspaceDir, params.slug);
if (!params.force && (await fileExists(targetDir))) {
return {
ok: false,
@@ -241,9 +283,9 @@ export async function installSkillFromClawHub(params: {
};
}
params.logger?.info?.(`Downloading ${slug}@${version} from ClawHub…`);
params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`);
const archive = await downloadClawHubSkillArchive({
slug,
slug: params.slug,
version,
baseUrl: params.baseUrl,
});
@@ -256,7 +298,7 @@ export async function installSkillFromClawHub(params: {
onExtracted: async (rootDir) =>
await installExtractedSkill({
workspaceDir: params.workspaceDir,
slug,
slug: params.slug,
extractedRoot: rootDir,
mode: params.force ? "update" : "install",
logger: params.logger,
@@ -270,12 +312,12 @@ export async function installSkillFromClawHub(params: {
await writeClawHubSkillOrigin(install.targetDir, {
version: 1,
registry: resolveClawHubBaseUrl(params.baseUrl),
slug,
slug: params.slug,
installedVersion: version,
installedAt,
});
const lock = await readClawHubSkillsLockfile(params.workspaceDir);
lock.skills[slug] = {
lock.skills[params.slug] = {
version,
installedAt,
};
@@ -283,7 +325,7 @@ export async function installSkillFromClawHub(params: {
return {
ok: true,
slug,
slug: params.slug,
version,
targetDir: install.targetDir,
detail,
@@ -302,6 +344,72 @@ export async function installSkillFromClawHub(params: {
}
}
async function installRequestedSkillFromClawHub(
params: ClawHubInstallParams,
): Promise<InstallClawHubSkillResult> {
try {
return await performClawHubSkillInstall({
...params,
slug: validateRequestedSlug(params.slug),
});
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
async function installTrackedSkillFromClawHub(
params: ClawHubInstallParams,
): Promise<InstallClawHubSkillResult> {
try {
return await performClawHubSkillInstall({
...params,
slug: normalizeTrackedSlug(params.slug),
});
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
async function resolveTrackedUpdateTarget(params: {
workspaceDir: string;
slug: string;
lock: ClawHubSkillsLockfile;
baseUrl?: string;
}): Promise<TrackedUpdateTarget> {
const targetDir = resolveSkillInstallDir(params.workspaceDir, params.slug);
const origin = (await readClawHubSkillOrigin(targetDir)) ?? null;
if (!origin && !params.lock.skills[params.slug]) {
return {
ok: false,
slug: params.slug,
error: `Skill "${params.slug}" is not tracked as a ClawHub install.`,
};
}
return {
ok: true,
slug: params.slug,
baseUrl: origin?.registry ?? params.baseUrl,
previousVersion: origin?.installedVersion ?? params.lock.skills[params.slug]?.version ?? null,
};
}
export async function installSkillFromClawHub(params: {
workspaceDir: string;
slug: string;
version?: string;
baseUrl?: string;
force?: boolean;
logger?: Logger;
}): Promise<InstallClawHubSkillResult> {
return await installRequestedSkillFromClawHub(params);
}
export async function updateSkillsFromClawHub(params: {
workspaceDir: string;
slug?: string;
@@ -309,24 +417,34 @@ export async function updateSkillsFromClawHub(params: {
logger?: Logger;
}): Promise<UpdateClawHubSkillResult[]> {
const lock = await readClawHubSkillsLockfile(params.workspaceDir);
const slugs = params.slug ? [normalizeSlug(params.slug)] : Object.keys(lock.skills);
const slugs = params.slug
? [
await resolveRequestedUpdateSlug({
workspaceDir: params.workspaceDir,
requestedSlug: params.slug,
lock,
}),
]
: Object.keys(lock.skills).map((slug) => normalizeTrackedSlug(slug));
const results: UpdateClawHubSkillResult[] = [];
for (const slug of slugs) {
const targetDir = resolveSkillInstallDir(params.workspaceDir, slug);
const origin = (await readClawHubSkillOrigin(targetDir)) ?? null;
const baseUrl = origin?.registry ?? params.baseUrl;
if (!origin && !lock.skills[slug]) {
const tracked = await resolveTrackedUpdateTarget({
workspaceDir: params.workspaceDir,
slug,
lock,
baseUrl: params.baseUrl,
});
if (!tracked.ok) {
results.push({
ok: false,
error: `Skill "${slug}" is not tracked as a ClawHub install.`,
error: tracked.error,
});
continue;
}
const previousVersion = origin?.installedVersion ?? lock.skills[slug]?.version ?? null;
const install = await installSkillFromClawHub({
const install = await installTrackedSkillFromClawHub({
workspaceDir: params.workspaceDir,
slug,
baseUrl,
slug: tracked.slug,
baseUrl: tracked.baseUrl,
force: true,
logger: params.logger,
});
@@ -336,10 +454,10 @@ export async function updateSkillsFromClawHub(params: {
}
results.push({
ok: true,
slug,
previousVersion,
slug: tracked.slug,
previousVersion: tracked.previousVersion,
version: install.version,
changed: previousVersion !== install.version,
changed: tracked.previousVersion !== install.version,
targetDir: install.targetDir,
});
}
+171 -22
View File
@@ -20,13 +20,16 @@ export type CommandAuthorization = {
to?: string;
};
function resolveProviderFromContext(ctx: MsgContext, cfg: OpenClawConfig): ChannelId | undefined {
function resolveProviderFromContext(
ctx: MsgContext,
cfg: OpenClawConfig,
): { providerId: ChannelId | undefined; hadResolutionError: boolean } {
const explicitMessageChannel =
normalizeMessageChannel(ctx.Provider) ??
normalizeMessageChannel(ctx.Surface) ??
normalizeMessageChannel(ctx.OriginatingChannel);
if (explicitMessageChannel === INTERNAL_MESSAGE_CHANNEL) {
return undefined;
return { providerId: undefined, hadResolutionError: false };
}
const direct =
normalizeAnyChannelId(explicitMessageChannel ?? undefined) ??
@@ -35,7 +38,7 @@ function resolveProviderFromContext(ctx: MsgContext, cfg: OpenClawConfig): Chann
normalizeAnyChannelId(ctx.Surface) ??
normalizeAnyChannelId(ctx.OriginatingChannel);
if (direct) {
return direct;
return { providerId: direct, hadResolutionError: false };
}
const candidates = [ctx.From, ctx.To]
.filter((value): value is string => Boolean(value?.trim()))
@@ -43,35 +46,52 @@ function resolveProviderFromContext(ctx: MsgContext, cfg: OpenClawConfig): Chann
for (const candidate of candidates) {
const normalizedCandidateChannel = normalizeMessageChannel(candidate);
if (normalizedCandidateChannel === INTERNAL_MESSAGE_CHANNEL) {
return undefined;
return { providerId: undefined, hadResolutionError: false };
}
const normalized =
normalizeAnyChannelId(normalizedCandidateChannel ?? undefined) ??
(normalizedCandidateChannel as ChannelId | undefined) ??
normalizeAnyChannelId(candidate);
if (normalized) {
return normalized;
return { providerId: normalized, hadResolutionError: false };
}
}
const configured = listChannelPlugins()
.map((plugin) => {
if (!plugin.config?.resolveAllowFrom) {
return null;
}
const allowFrom = plugin.config.resolveAllowFrom({
const resolvedAllowFrom = resolveProviderAllowFrom({
plugin,
cfg,
accountId: ctx.AccountId,
});
if (!Array.isArray(allowFrom) || allowFrom.length === 0) {
const allowFrom = formatAllowFromList({
plugin,
cfg,
accountId: ctx.AccountId,
allowFrom: resolvedAllowFrom.allowFrom,
});
if (allowFrom.length === 0) {
return null;
}
return plugin.id;
return {
providerId: plugin.id,
hadResolutionError: resolvedAllowFrom.hadResolutionError,
};
})
.filter((value): value is ChannelId => Boolean(value));
.filter(
(
value,
): value is {
providerId: ChannelId;
hadResolutionError: boolean;
} => Boolean(value),
);
if (configured.length === 1) {
return configured[0];
}
return undefined;
return {
providerId: undefined,
hadResolutionError: configured.some((entry) => entry.hadResolutionError),
};
}
function formatAllowFromList(params: {
@@ -105,6 +125,63 @@ function normalizeAllowFromEntry(params: {
return normalized.filter((entry) => entry.trim().length > 0);
}
function resolveProviderAllowFrom(params: {
plugin?: ChannelPlugin;
cfg: OpenClawConfig;
accountId?: string | null;
}): {
allowFrom: Array<string | number>;
hadResolutionError: boolean;
} {
const { plugin, cfg, accountId } = params;
const providerId = plugin?.id;
if (!plugin?.config?.resolveAllowFrom) {
return {
allowFrom: resolveFallbackAllowFrom({ cfg, providerId, accountId }),
hadResolutionError: false,
};
}
try {
const allowFrom = plugin.config.resolveAllowFrom({ cfg, accountId });
if (allowFrom == null) {
return {
allowFrom: [],
hadResolutionError: false,
};
}
if (!Array.isArray(allowFrom)) {
console.warn(
`[command-auth] resolveAllowFrom returned an invalid allowFrom for provider "${providerId}", falling back to config allowFrom: invalid_result`,
);
return {
allowFrom: resolveFallbackAllowFrom({ cfg, providerId, accountId }),
hadResolutionError: true,
};
}
return {
allowFrom,
hadResolutionError: false,
};
} catch (err) {
console.warn(
`[command-auth] resolveAllowFrom threw for provider "${providerId}", falling back to config allowFrom: ${describeAllowFromResolutionError(err)}`,
);
return {
allowFrom: resolveFallbackAllowFrom({ cfg, providerId, accountId }),
hadResolutionError: true,
};
}
}
function describeAllowFromResolutionError(err: unknown): string {
if (err instanceof Error) {
const name = err.name.trim();
return name || "Error";
}
return "unknown_error";
}
function resolveOwnerAllowFromList(params: {
plugin?: ChannelPlugin;
cfg: OpenClawConfig;
@@ -283,7 +360,9 @@ function resolveFallbackAllowFrom(params: {
>
| undefined;
const channelCfg = channels?.[providerId];
const accountCfg = params.accountId ? channelCfg?.accounts?.[params.accountId] : undefined;
const accountCfg =
resolveFallbackAccountConfig(channelCfg?.accounts, params.accountId) ??
resolveFallbackDefaultAccountConfig(channelCfg);
const allowFrom =
accountCfg?.allowFrom ??
accountCfg?.dm?.allowFrom ??
@@ -292,6 +371,64 @@ function resolveFallbackAllowFrom(params: {
return Array.isArray(allowFrom) ? allowFrom : [];
}
function resolveFallbackAccountConfig(
accounts:
| Record<
string,
| {
allowFrom?: Array<string | number>;
dm?: { allowFrom?: Array<string | number> };
}
| undefined
>
| undefined,
accountId?: string | null,
) {
const normalizedAccountId = accountId?.trim().toLowerCase();
if (!accounts || !normalizedAccountId) {
return undefined;
}
const direct = accounts[normalizedAccountId];
if (direct) {
return direct;
}
const matchKey = Object.keys(accounts).find(
(key) => key.trim().toLowerCase() === normalizedAccountId,
);
return matchKey ? accounts[matchKey] : undefined;
}
function resolveFallbackDefaultAccountConfig(
channelCfg:
| {
allowFrom?: Array<string | number>;
dm?: { allowFrom?: Array<string | number> };
defaultAccount?: string;
accounts?: Record<
string,
| {
allowFrom?: Array<string | number>;
dm?: { allowFrom?: Array<string | number> };
}
| undefined
>;
}
| undefined,
) {
const accounts = channelCfg?.accounts;
if (!accounts) {
return undefined;
}
const preferred =
resolveFallbackAccountConfig(accounts, channelCfg?.defaultAccount) ??
resolveFallbackAccountConfig(accounts, "default");
if (preferred) {
return preferred;
}
const definedAccounts = Object.values(accounts).filter(Boolean);
return definedAccounts.length === 1 ? definedAccounts[0] : undefined;
}
function resolveFallbackCommandOptions(providerId?: ChannelId): {
enforceOwnerForCommands: boolean;
} {
@@ -306,7 +443,10 @@ export function resolveCommandAuthorization(params: {
commandAuthorized: boolean;
}): CommandAuthorization {
const { ctx, cfg, commandAuthorized } = params;
const providerId = resolveProviderFromContext(ctx, cfg);
const { providerId, hadResolutionError: providerResolutionError } = resolveProviderFromContext(
ctx,
cfg,
);
const plugin = providerId ? getChannelPlugin(providerId) : undefined;
const from = (ctx.From ?? "").trim();
const to = (ctx.To ?? "").trim();
@@ -319,18 +459,25 @@ export function resolveCommandAuthorization(params: {
providerId,
});
const allowFromRaw = plugin?.config?.resolveAllowFrom
? plugin.config.resolveAllowFrom({ cfg, accountId: ctx.AccountId })
: resolveFallbackAllowFrom({
const resolvedAllowFrom = providerResolutionError
? {
allowFrom: resolveFallbackAllowFrom({
cfg,
providerId,
accountId: ctx.AccountId,
}),
hadResolutionError: true,
}
: resolveProviderAllowFrom({
plugin,
cfg,
providerId,
accountId: ctx.AccountId,
});
const allowFromList = formatAllowFromList({
plugin,
cfg,
accountId: ctx.AccountId,
allowFrom: Array.isArray(allowFromRaw) ? allowFromRaw : [],
allowFrom: resolvedAllowFrom.allowFrom,
});
const configOwnerAllowFromList = resolveOwnerAllowFromList({
plugin,
@@ -347,7 +494,8 @@ export function resolveCommandAuthorization(params: {
allowFrom: ctx.OwnerAllowFrom,
});
const allowAll =
allowFromList.length === 0 || allowFromList.some((entry) => entry.trim() === "*");
!resolvedAllowFrom.hadResolutionError &&
(allowFromList.length === 0 || allowFromList.some((entry) => entry.trim() === "*"));
const ownerCandidatesForCommands = allowAll ? [] : allowFromList.filter((entry) => entry !== "*");
if (!allowAll && ownerCandidatesForCommands.length === 0 && to) {
@@ -423,7 +571,8 @@ export function resolveCommandAuthorization(params: {
const matchedCommandsAllowFrom = commandsAllowFromList.length
? senderCandidates.find((candidate) => commandsAllowFromList.includes(candidate))
: undefined;
isAuthorizedSender = commandsAllowAll || Boolean(matchedCommandsAllowFrom);
isAuthorizedSender =
!providerResolutionError && (commandsAllowAll || Boolean(matchedCommandsAllowFrom));
} else {
// Fall back to existing behavior
isAuthorizedSender = commandAuthorized && isOwnerForCommands;
+299 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
@@ -181,6 +181,50 @@ describe("resolveCommandAuthorization", () => {
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back to channel allowFrom when provider allowlist resolution throws", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: {
...createOutboundTestPlugin({
id: "telegram",
outbound: { deliveryMode: "direct" },
}),
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: () => {
throw new Error("channels.telegram.botToken: unresolved SecretRef");
},
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
},
source: "test",
},
]),
);
const cfg = {
channels: { telegram: { allowFrom: ["123"] } },
} as OpenClawConfig;
const auth = resolveCommandAuthorization({
ctx: {
Provider: "telegram",
Surface: "telegram",
From: "telegram:123",
SenderId: "123",
} as MsgContext,
cfg,
commandAuthorized: true,
});
expect(auth.ownerList).toEqual(["123"]);
expect(auth.senderIsOwner).toBe(true);
expect(auth.isAuthorizedSender).toBe(true);
});
describe("commands.allowFrom", () => {
const commandsAllowFromConfig = {
commands: {
@@ -443,6 +487,260 @@ describe("resolveCommandAuthorization", () => {
expect(deniedAuth.isAuthorizedSender).toBe(false);
});
it("fails closed when provider inference hits unresolved SecretRef allowlists", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: {
...createOutboundTestPlugin({
id: "telegram",
outbound: { deliveryMode: "direct" },
}),
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: () => {
throw new Error("channels.telegram.botToken: unresolved SecretRef");
},
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
},
source: "test",
},
]),
);
const cfg = {
commands: {
allowFrom: {
telegram: ["123"],
},
},
channels: {
telegram: {
allowFrom: ["123"],
},
},
} as OpenClawConfig;
const auth = resolveCommandAuthorization({
ctx: {
SenderId: "123",
} as MsgContext,
cfg,
commandAuthorized: false,
});
expect(auth.providerId).toBe("telegram");
expect(auth.isAuthorizedSender).toBe(false);
});
it("does not let an unrelated provider resolution error poison inferred commands.allowFrom", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: {
...createOutboundTestPlugin({
id: "telegram",
outbound: { deliveryMode: "direct" },
}),
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: () => ["123"],
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
},
source: "test",
},
{
pluginId: "slack",
plugin: {
...createOutboundTestPlugin({
id: "slack",
outbound: { deliveryMode: "direct" },
}),
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: () => {
throw new Error("channels.slack.token: unresolved SecretRef");
},
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
},
source: "test",
},
]),
);
const auth = resolveCommandAuthorization({
ctx: {
SenderId: "123",
} as MsgContext,
cfg: {
commands: {
allowFrom: {
telegram: ["123"],
},
},
channels: {
telegram: {
allowFrom: ["123"],
},
},
} as OpenClawConfig,
commandAuthorized: false,
});
expect(auth.providerId).toBe("telegram");
expect(auth.isAuthorizedSender).toBe(true);
});
it("preserves default-account allowFrom on SecretRef fallback", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: {
...createOutboundTestPlugin({
id: "telegram",
outbound: { deliveryMode: "direct" },
}),
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: () => {
throw new Error("channels.telegram.botToken: unresolved SecretRef");
},
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
},
source: "test",
},
]),
);
const auth = resolveCommandAuthorization({
ctx: {
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
} as MsgContext,
cfg: {
channels: {
telegram: {
accounts: {
default: {
allowFrom: ["123"],
},
},
},
},
} as OpenClawConfig,
commandAuthorized: true,
});
expect(auth.ownerList).toEqual(["123"]);
expect(auth.isAuthorizedSender).toBe(true);
});
it("treats undefined allowFrom as an open channel, not a resolution failure", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "discord",
plugin: {
...createOutboundTestPlugin({
id: "discord",
outbound: { deliveryMode: "direct" },
}),
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: () => undefined,
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
},
source: "test",
},
]),
);
const auth = resolveCommandAuthorization({
ctx: {
Provider: "discord",
Surface: "discord",
SenderId: "123",
} as MsgContext,
cfg: {
channels: {
discord: {},
},
} as OpenClawConfig,
commandAuthorized: true,
});
expect(auth.isAuthorizedSender).toBe(true);
});
it("does not log raw resolution messages from thrown allowFrom errors", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: {
...createOutboundTestPlugin({
id: "telegram",
outbound: { deliveryMode: "direct" },
}),
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: () => {
throw new Error("SECRET-TOKEN-123");
},
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
},
source: "test",
},
]),
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
resolveCommandAuthorization({
ctx: {
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
} as MsgContext,
cfg: {
channels: {
telegram: {
allowFrom: ["123"],
},
},
} as OpenClawConfig,
commandAuthorized: true,
});
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0] ?? "")).toContain("Error");
expect(String(warn.mock.calls[0]?.[0] ?? "")).not.toContain("SECRET-TOKEN-123");
} finally {
warn.mockRestore();
}
});
});
it("grants senderIsOwner for internal channel with operator.admin scope", () => {
@@ -48,10 +48,13 @@ export function buildThreadingToolContext(params: {
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
const threading = provider ? getChannelPlugin(provider)?.threading : undefined;
if (!threading?.buildToolContext) {
const threadTs =
sessionCtx.MessageThreadId != null ? String(sessionCtx.MessageThreadId) : undefined;
return {
currentChannelId: originTo?.trim() || undefined,
currentChannelProvider: provider ?? (rawProvider as ChannelId),
currentMessageId,
currentThreadTs: threadTs || undefined,
hasRepliedRef,
};
}
+20 -4
View File
@@ -1,5 +1,4 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { resolveBrowserExecutableForPlatform } from "./chrome.executables.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", () => ({
execFileSync: vi.fn(),
@@ -13,8 +12,21 @@ vi.mock("node:fs", () => {
default: { existsSync, readFileSync },
};
});
vi.mock("node:os", () => {
const homedir = vi.fn();
return {
homedir,
default: { homedir },
};
});
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import os from "node:os";
async function loadResolveBrowserExecutableForPlatform() {
const mod = await import("./chrome.executables.js");
return mod.resolveBrowserExecutableForPlatform;
}
describe("browser default executable detection", () => {
const launchServicesPlist = "com.apple.launchservices.secure.plist";
@@ -47,12 +59,15 @@ describe("browser default executable detection", () => {
}
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.mocked(os.homedir).mockReturnValue("/Users/test");
});
it("prefers default Chromium browser on macOS", () => {
it("prefers default Chromium browser on macOS", async () => {
mockMacDefaultBrowser("com.google.Chrome", "/Applications/Google Chrome.app");
mockChromeExecutableExists();
const resolveBrowserExecutableForPlatform = await loadResolveBrowserExecutableForPlatform();
const exe = resolveBrowserExecutableForPlatform(
{} as Parameters<typeof resolveBrowserExecutableForPlatform>[0],
@@ -63,9 +78,10 @@ describe("browser default executable detection", () => {
expect(exe?.kind).toBe("chrome");
});
it("falls back when default browser is non-Chromium on macOS", () => {
it("falls back when default browser is non-Chromium on macOS", async () => {
mockMacDefaultBrowser("com.apple.Safari");
mockChromeExecutableExists();
const resolveBrowserExecutableForPlatform = await loadResolveBrowserExecutableForPlatform();
const exe = resolveBrowserExecutableForPlatform(
{} as Parameters<typeof resolveBrowserExecutableForPlatform>[0],
@@ -7,6 +7,10 @@ vi.hoisted(() => {
});
import "./server-context.chrome-test-harness.js";
import {
PROFILE_ATTACH_RETRY_TIMEOUT_MS,
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
} from "./cdp-timeouts.js";
import * as chromeModule from "./chrome.js";
import type { RunningChrome } from "./chrome.js";
import type { BrowserServerState } from "./server-context.js";
@@ -121,12 +125,22 @@ describe("browser server-context ensureBrowserAvailable", () => {
await expect(profile.ensureBrowserAvailable()).resolves.toBeUndefined();
expect(isChromeReachable).toHaveBeenNthCalledWith(1, "http://127.0.0.1:18800", undefined, {
allowPrivateNetwork: true,
});
expect(isChromeReachable).toHaveBeenNthCalledWith(2, "http://127.0.0.1:18800", 1000, {
allowPrivateNetwork: true,
});
expect(isChromeReachable).toHaveBeenNthCalledWith(
1,
"http://127.0.0.1:18800",
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
{
allowPrivateNetwork: true,
},
);
expect(isChromeReachable).toHaveBeenNthCalledWith(
2,
"http://127.0.0.1:18800",
PROFILE_ATTACH_RETRY_TIMEOUT_MS,
{
allowPrivateNetwork: true,
},
);
expect(launchOpenClawChrome).not.toHaveBeenCalled();
expect(stopOpenClawChrome).not.toHaveBeenCalled();
});
@@ -234,6 +234,7 @@ describe("channel plugin catalog", () => {
env: {
...process.env,
OPENCLAW_PLUGIN_CATALOG_PATHS: "~/catalog.json",
OPENCLAW_HOME: home,
HOME: home,
},
}).map((entry) => entry.id);
+131 -31
View File
@@ -5,12 +5,13 @@ const mocks = vi.hoisted(() => ({
resolveAgentWorkspaceDir: vi.fn(),
resolveDefaultAgentId: vi.fn(),
getChannelPluginCatalogEntry: vi.fn(),
listChannelPluginCatalogEntries: vi.fn(),
resolveChannelDefaultAccountId: vi.fn(),
getChannelPlugin: vi.fn(),
listChannelPlugins: vi.fn(),
normalizeChannelId: vi.fn(),
loadConfig: vi.fn(),
writeConfigFile: vi.fn(),
resolveMessageChannelSelection: vi.fn(),
setVerbose: vi.fn(),
createClackPrompter: vi.fn(),
ensureChannelSetupPluginInstalled: vi.fn(),
@@ -27,6 +28,7 @@ vi.mock("../agents/agent-scope.js", () => ({
vi.mock("../channels/plugins/catalog.js", () => ({
getChannelPluginCatalogEntry: mocks.getChannelPluginCatalogEntry,
listChannelPluginCatalogEntries: mocks.listChannelPluginCatalogEntries,
}));
vi.mock("../channels/plugins/helpers.js", () => ({
@@ -35,6 +37,7 @@ vi.mock("../channels/plugins/helpers.js", () => ({
vi.mock("../channels/plugins/index.js", () => ({
getChannelPlugin: mocks.getChannelPlugin,
listChannelPlugins: mocks.listChannelPlugins,
normalizeChannelId: mocks.normalizeChannelId,
}));
@@ -43,10 +46,6 @@ vi.mock("../config/config.js", () => ({
writeConfigFile: mocks.writeConfigFile,
}));
vi.mock("../infra/outbound/channel-selection.js", () => ({
resolveMessageChannelSelection: mocks.resolveMessageChannelSelection,
}));
vi.mock("../globals.js", () => ({
setVerbose: mocks.setVerbose,
}));
@@ -67,7 +66,10 @@ describe("channel-auth", () => {
id: "whatsapp",
auth: { login: mocks.login },
gateway: { logoutAccount: mocks.logoutAccount },
config: { resolveAccount: mocks.resolveAccount },
config: {
listAccountIds: vi.fn().mockReturnValue(["default"]),
resolveAccount: mocks.resolveAccount,
},
};
beforeEach(() => {
@@ -75,18 +77,16 @@ describe("channel-auth", () => {
mocks.normalizeChannelId.mockReturnValue("whatsapp");
mocks.getChannelPlugin.mockReturnValue(plugin);
mocks.getChannelPluginCatalogEntry.mockReturnValue(undefined);
mocks.loadConfig.mockReturnValue({ channels: {} });
mocks.listChannelPluginCatalogEntries.mockReturnValue([]);
mocks.loadConfig.mockReturnValue({ channels: { whatsapp: {} } });
mocks.writeConfigFile.mockResolvedValue(undefined);
mocks.resolveMessageChannelSelection.mockResolvedValue({
channel: "whatsapp",
configured: ["whatsapp"],
});
mocks.listChannelPlugins.mockReturnValue([plugin]);
mocks.resolveDefaultAgentId.mockReturnValue("main");
mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace");
mocks.resolveChannelDefaultAccountId.mockReturnValue("default-account");
mocks.createClackPrompter.mockReturnValue({} as object);
mocks.ensureChannelSetupPluginInstalled.mockResolvedValue({
cfg: { channels: {} },
cfg: { channels: { whatsapp: {} } },
installed: true,
pluginId: "whatsapp",
});
@@ -106,7 +106,7 @@ describe("channel-auth", () => {
expect(mocks.resolveChannelDefaultAccountId).not.toHaveBeenCalled();
expect(mocks.login).toHaveBeenCalledWith(
expect.objectContaining({
cfg: { channels: {} },
cfg: { channels: { whatsapp: {} } },
accountId: "acct-1",
runtime,
verbose: true,
@@ -115,10 +115,9 @@ describe("channel-auth", () => {
);
});
it("auto-picks the single configured channel when opts are empty", async () => {
it("auto-picks the single configured channel that supports login when opts are empty", async () => {
await runChannelLogin({}, runtime);
expect(mocks.resolveMessageChannelSelection).toHaveBeenCalledWith({ cfg: { channels: {} } });
expect(mocks.normalizeChannelId).toHaveBeenCalledWith("whatsapp");
expect(mocks.login).toHaveBeenCalledWith(
expect.objectContaining({
@@ -127,17 +126,72 @@ describe("channel-auth", () => {
);
});
it("propagates channel ambiguity when channel is omitted", async () => {
mocks.resolveMessageChannelSelection.mockRejectedValueOnce(
new Error("Channel is required when multiple channels are configured: telegram, slack"),
it("ignores configured channels that do not support login when channel is omitted", async () => {
const telegramPlugin = {
id: "telegram",
auth: {},
gateway: {},
config: {
listAccountIds: vi.fn().mockReturnValue(["default"]),
resolveAccount: vi.fn().mockReturnValue({ enabled: true }),
},
};
mocks.loadConfig.mockReturnValue({ channels: { whatsapp: {}, telegram: {} } });
mocks.listChannelPlugins.mockReturnValue([telegramPlugin, plugin]);
await runChannelLogin({}, runtime);
expect(mocks.normalizeChannelId).toHaveBeenCalledWith("whatsapp");
expect(mocks.login).toHaveBeenCalled();
});
it("propagates auth-channel ambiguity when multiple configured channels support login", async () => {
const zaloPlugin = {
id: "zalouser",
auth: { login: vi.fn() },
gateway: {},
config: {
listAccountIds: vi.fn().mockReturnValue(["default"]),
resolveAccount: vi.fn().mockReturnValue({ enabled: true }),
},
};
mocks.loadConfig.mockReturnValue({ channels: { whatsapp: {}, zalouser: {} } });
mocks.listChannelPlugins.mockReturnValue([plugin, zaloPlugin]);
mocks.normalizeChannelId.mockImplementation((value) => value);
mocks.getChannelPlugin.mockImplementation((value) =>
value === "whatsapp"
? plugin
: value === "zalouser"
? (zaloPlugin as typeof plugin)
: undefined,
);
await expect(runChannelLogin({}, runtime)).rejects.toThrow("Channel is required");
await expect(runChannelLogin({}, runtime)).rejects.toThrow(
"multiple configured channels support login: whatsapp, zalouser",
);
expect(mocks.login).not.toHaveBeenCalled();
});
it("ignores plugins with prototype-chain IDs like __proto__", async () => {
const protoPlugin = {
id: "__proto__",
auth: { login: vi.fn() },
gateway: {},
config: {
listAccountIds: vi.fn().mockReturnValue(["default"]),
resolveAccount: vi.fn().mockReturnValue({ enabled: true }),
},
};
mocks.listChannelPlugins.mockReturnValue([protoPlugin, plugin]);
await runChannelLogin({}, runtime);
expect(mocks.normalizeChannelId).toHaveBeenCalledWith("whatsapp");
expect(mocks.login).toHaveBeenCalled();
});
it("throws for unsupported channel aliases", async () => {
mocks.normalizeChannelId.mockReturnValueOnce(undefined);
mocks.normalizeChannelId.mockImplementation(() => undefined);
await expect(runChannelLogin({ channel: "bad-channel" }, runtime)).rejects.toThrow(
"Unsupported channel: bad-channel",
@@ -158,8 +212,7 @@ describe("channel-auth", () => {
});
it("installs a catalog-backed channel plugin on demand for login", async () => {
mocks.getChannelPlugin.mockReturnValueOnce(undefined);
mocks.getChannelPluginCatalogEntry.mockReturnValueOnce({
const catalogEntry = {
id: "whatsapp",
pluginId: "@openclaw/whatsapp",
meta: {
@@ -172,7 +225,58 @@ describe("channel-auth", () => {
install: {
npmSpec: "@openclaw/whatsapp",
},
});
};
mocks.getChannelPlugin.mockReturnValueOnce(undefined);
mocks.listChannelPluginCatalogEntries.mockReturnValueOnce([catalogEntry]);
mocks.loadChannelSetupPluginRegistrySnapshotForChannel
.mockReturnValueOnce({
channels: [],
channelSetups: [],
})
.mockReturnValueOnce({
channels: [{ plugin }],
channelSetups: [],
});
await runChannelLogin({ channel: "whatsapp" }, runtime);
expect(mocks.ensureChannelSetupPluginInstalled).toHaveBeenCalledWith(
expect.objectContaining({
entry: catalogEntry,
runtime,
workspaceDir: "/tmp/workspace",
}),
);
expect(mocks.loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({
channel: "whatsapp",
pluginId: "whatsapp",
workspaceDir: "/tmp/workspace",
}),
);
expect(mocks.writeConfigFile).toHaveBeenCalledWith({ channels: { whatsapp: {} } });
expect(mocks.login).toHaveBeenCalled();
});
it("resolves explicit channel login through the catalog when registry normalize misses", async () => {
mocks.normalizeChannelId.mockReturnValueOnce(undefined).mockReturnValue("whatsapp");
mocks.getChannelPlugin.mockReturnValueOnce(undefined);
mocks.listChannelPluginCatalogEntries.mockReturnValueOnce([
{
id: "whatsapp",
pluginId: "@openclaw/whatsapp",
meta: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp",
docsPath: "/channels/whatsapp",
blurb: "wa",
},
install: {
npmSpec: "@openclaw/whatsapp",
},
},
]);
mocks.loadChannelSetupPluginRegistrySnapshotForChannel
.mockReturnValueOnce({
channels: [],
@@ -192,23 +296,19 @@ describe("channel-auth", () => {
workspaceDir: "/tmp/workspace",
}),
);
expect(mocks.loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect(mocks.login).toHaveBeenCalledWith(
expect.objectContaining({
channel: "whatsapp",
pluginId: "whatsapp",
workspaceDir: "/tmp/workspace",
channelInput: "whatsapp",
}),
);
expect(mocks.writeConfigFile).toHaveBeenCalledWith({ channels: {} });
expect(mocks.login).toHaveBeenCalled();
});
it("runs logout with resolved account and explicit account id", async () => {
await runChannelLogout({ channel: "whatsapp", account: " acct-2 " }, runtime);
expect(mocks.resolveAccount).toHaveBeenCalledWith({ channels: {} }, "acct-2");
expect(mocks.resolveAccount).toHaveBeenCalledWith({ channels: { whatsapp: {} } }, "acct-2");
expect(mocks.logoutAccount).toHaveBeenCalledWith({
cfg: { channels: {} },
cfg: { channels: { whatsapp: {} } },
accountId: "acct-2",
account: { id: "resolved-account" },
runtime,
+74 -16
View File
@@ -1,10 +1,15 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import {
getChannelPlugin,
listChannelPlugins,
normalizeChannelId,
} from "../channels/plugins/index.js";
import { resolveInstallableChannelPlugin } from "../commands/channel-setup/channel-plugin-resolution.js";
import { loadConfig, writeConfigFile, type OpenClawConfig } from "../config/config.js";
import { setVerbose } from "../globals.js";
import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { sanitizeForLog } from "../terminal/ansi.js";
type ChannelAuthOptions = {
channel?: string;
@@ -15,6 +20,62 @@ type ChannelAuthOptions = {
type ChannelPlugin = NonNullable<ReturnType<typeof getChannelPlugin>>;
type ChannelAuthMode = "login" | "logout";
function supportsChannelAuthMode(plugin: ChannelPlugin, mode: ChannelAuthMode): boolean {
return mode === "login" ? Boolean(plugin.auth?.login) : Boolean(plugin.gateway?.logoutAccount);
}
function isConfiguredAuthPlugin(plugin: ChannelPlugin, cfg: OpenClawConfig): boolean {
const channels = cfg.channels as Record<string, unknown> | undefined;
const key = plugin.id;
if (
!channels ||
isBlockedObjectKey(key) ||
!Object.prototype.hasOwnProperty.call(channels, key)
) {
return false;
}
const channelCfg = channels[key];
if (!channelCfg || typeof channelCfg !== "object") {
return false;
}
for (const accountId of plugin.config.listAccountIds(cfg)) {
try {
const account = plugin.config.resolveAccount(cfg, accountId);
const enabled = plugin.config.isEnabled
? plugin.config.isEnabled(account, cfg)
: account && typeof account === "object"
? ((account as { enabled?: boolean }).enabled ?? true)
: true;
if (enabled) {
return true;
}
} catch {
continue;
}
}
return false;
}
function resolveConfiguredAuthChannelInput(cfg: OpenClawConfig, mode: ChannelAuthMode): string {
const configured = listChannelPlugins()
.filter((plugin): plugin is ChannelPlugin => supportsChannelAuthMode(plugin, mode))
.filter((plugin) => isConfiguredAuthPlugin(plugin, cfg))
.map((plugin) => plugin.id);
if (configured.length === 1) {
return configured[0];
}
if (configured.length === 0) {
throw new Error(`Channel is required (no configured channels support ${mode}).`);
}
const safeIds = configured.map(sanitizeForLog);
throw new Error(
`Channel is required when multiple configured channels support ${mode}: ${safeIds.join(", ")}`,
);
}
async function resolveChannelPluginForMode(
opts: ChannelAuthOptions,
mode: ChannelAuthMode,
@@ -28,26 +89,23 @@ async function resolveChannelPluginForMode(
plugin: ChannelPlugin;
}> {
const explicitChannel = opts.channel?.trim();
const channelInput = explicitChannel
? explicitChannel
: (await resolveMessageChannelSelection({ cfg })).channel;
const channelId = normalizeChannelId(channelInput);
if (!channelId) {
throw new Error(`Unsupported channel: ${channelInput}`);
}
const channelInput = explicitChannel || resolveConfiguredAuthChannelInput(cfg, mode);
const normalizedChannelId = normalizeChannelId(channelInput);
const resolved = await resolveInstallableChannelPlugin({
cfg,
runtime,
channelId,
rawChannel: channelInput,
...(normalizedChannelId ? { channelId: normalizedChannelId } : {}),
allowInstall: true,
supports: (candidate) =>
mode === "login" ? Boolean(candidate.auth?.login) : Boolean(candidate.gateway?.logoutAccount),
supports: (candidate) => supportsChannelAuthMode(candidate, mode),
});
const channelId = resolved.channelId ?? normalizedChannelId;
if (!channelId) {
throw new Error(`Unsupported channel: ${channelInput}`);
}
const plugin = resolved.plugin;
const supportsMode =
mode === "login" ? Boolean(plugin?.auth?.login) : Boolean(plugin?.gateway?.logoutAccount);
if (!supportsMode) {
if (!plugin || !supportsChannelAuthMode(plugin, mode)) {
throw new Error(`Channel ${channelId} does not support ${mode}`);
}
return {
@@ -55,7 +113,7 @@ async function resolveChannelPluginForMode(
configChanged: resolved.configChanged,
channelInput,
channelId,
plugin: plugin as ChannelPlugin,
plugin,
};
}
+100
View File
@@ -614,6 +614,88 @@ describe("cron cli", () => {
]);
});
it("rejects --tz with --every on cron add", async () => {
await expectCronCommandExit([
"cron",
"add",
"--name",
"invalid",
"--every",
"10m",
"--tz",
"UTC",
"--session",
"main",
"--system-event",
"tick",
]);
});
it("applies --tz to --at for offset-less datetimes on cron add", async () => {
await runCronCommand([
"cron",
"add",
"--name",
"tz-at-test",
"--at",
"2026-03-23T23:00:00",
"--tz",
"Europe/Oslo",
"--session",
"isolated",
"--message",
"test",
]);
const params = getGatewayCallParams<{ schedule: { kind: string; at: string } }>("cron.add");
// 2026-03-23 is CET (+01:00), so 23:00 Oslo = 22:00 UTC
expect(params.schedule.kind).toBe("at");
expect(params.schedule.at).toBe("2026-03-23T22:00:00.000Z");
});
it("does not apply --tz when --at already has an offset", async () => {
await runCronCommand([
"cron",
"add",
"--name",
"tz-at-offset-test",
"--at",
"2026-03-23T23:00:00+02:00",
"--tz",
"Europe/Oslo",
"--session",
"isolated",
"--message",
"test",
]);
const params = getGatewayCallParams<{ schedule: { kind: string; at: string } }>("cron.add");
// Explicit +02:00 should be honored, not overridden by --tz
expect(params.schedule.kind).toBe("at");
expect(params.schedule.at).toBe("2026-03-23T21:00:00.000Z");
});
it("applies --tz to --at correctly across DST boundaries on cron add", async () => {
await runCronCommand([
"cron",
"add",
"--name",
"tz-at-dst-test",
"--at",
"2026-03-29T01:30:00",
"--tz",
"Europe/Oslo",
"--session",
"isolated",
"--message",
"test",
]);
const params = getGatewayCallParams<{ schedule: { kind: string; at: string } }>("cron.add");
expect(params.schedule.kind).toBe("at");
expect(params.schedule.at).toBe("2026-03-29T00:30:00.000Z");
});
it("sets explicit stagger for cron edit", async () => {
await runCronCommand(["cron", "edit", "job-1", "--cron", "0 * * * *", "--stagger", "30s"]);
@@ -641,6 +723,24 @@ describe("cron cli", () => {
await expectCronEditWithScheduleLookupExit({ kind: "every", everyMs: 60_000 }, ["--exact"]);
});
it("applies --tz to --at for offset-less datetimes on cron edit", async () => {
const patch = await runCronEditAndGetPatch([
"--at",
"2026-03-23T23:00:00",
"--tz",
"Europe/Oslo",
]);
expect(patch?.patch?.schedule).toEqual({
kind: "at",
at: "2026-03-23T22:00:00.000Z",
});
});
it("rejects --tz with --every on cron edit", async () => {
await expectCronCommandExit(["cron", "edit", "job-1", "--every", "10m", "--tz", "UTC"]);
});
it("patches failure alert settings on cron edit", async () => {
callGatewayFromCli.mockClear();
+13 -43
View File
@@ -5,12 +5,10 @@ import { defaultRuntime } from "../../runtime.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { parsePositiveIntOrUndefined } from "../program/helpers.js";
import { resolveCronCreateSchedule } from "./schedule-options.js";
import {
getCronChannelOptions,
handleCronCliError,
parseAt,
parseCronStaggerMs,
parseDurationMs,
printCronJson,
printCronList,
warnIfCronSchedulerDisabled,
@@ -73,7 +71,10 @@ export function registerCronAddCommand(cron: Command) {
.option("--session <target>", "Session target (main|isolated)")
.option("--session-key <key>", "Session key for job routing (e.g. agent:my-agent:my-session)")
.option("--wake <mode>", "Wake mode (now|next-heartbeat)", "now")
.option("--at <when>", "Run once at time (ISO) or +duration (e.g. 20m)")
.option(
"--at <when>",
"Run once at time (ISO with offset, or +duration). Use --tz for offset-less datetimes",
)
.option("--every <duration>", "Run every duration (e.g. 10m, 1h)")
.option("--cron <expr>", "Cron expression (5-field or 6-field with seconds)")
.option("--tz <iana>", "Timezone for cron expressions (IANA)", "")
@@ -101,45 +102,14 @@ export function registerCronAddCommand(cron: Command) {
.option("--json", "Output JSON", false)
.action(async (opts: GatewayRpcOpts & Record<string, unknown>, cmd?: Command) => {
try {
const staggerRaw = typeof opts.stagger === "string" ? opts.stagger.trim() : "";
const useExact = Boolean(opts.exact);
if (staggerRaw && useExact) {
throw new Error("Choose either --stagger or --exact, not both");
}
const schedule = (() => {
const at = typeof opts.at === "string" ? opts.at : "";
const every = typeof opts.every === "string" ? opts.every : "";
const cronExpr = typeof opts.cron === "string" ? opts.cron : "";
const chosen = [Boolean(at), Boolean(every), Boolean(cronExpr)].filter(Boolean).length;
if (chosen !== 1) {
throw new Error("Choose exactly one schedule: --at, --every, or --cron");
}
if ((useExact || staggerRaw) && !cronExpr) {
throw new Error("--stagger/--exact are only valid with --cron");
}
if (at) {
const atIso = parseAt(at);
if (!atIso) {
throw new Error("Invalid --at; use ISO time or duration like 20m");
}
return { kind: "at" as const, at: atIso };
}
if (every) {
const everyMs = parseDurationMs(every);
if (!everyMs) {
throw new Error("Invalid --every; use e.g. 10m, 1h, 1d");
}
return { kind: "every" as const, everyMs };
}
const staggerMs = parseCronStaggerMs({ staggerRaw, useExact });
return {
kind: "cron" as const,
expr: cronExpr,
tz: typeof opts.tz === "string" && opts.tz.trim() ? opts.tz.trim() : undefined,
staggerMs,
};
})();
const schedule = resolveCronCreateSchedule({
at: opts.at,
cron: opts.cron,
every: opts.every,
exact: opts.exact,
stagger: opts.stagger,
tz: opts.tz,
});
const wakeModeRaw = typeof opts.wake === "string" ? opts.wake : "now";
const wakeMode = wakeModeRaw.trim() || "now";
+16 -55
View File
@@ -5,12 +5,10 @@ import { sanitizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import {
getCronChannelOptions,
parseAt,
parseCronStaggerMs,
parseDurationMs,
warnIfCronSchedulerDisabled,
} from "./shared.js";
applyExistingCronSchedulePatch,
resolveCronEditScheduleRequest,
} from "./schedule-options.js";
import { getCronChannelOptions, parseDurationMs, warnIfCronSchedulerDisabled } from "./shared.js";
const assignIf = (
target: Record<string, unknown>,
@@ -97,13 +95,6 @@ export function registerCronEditCommand(cron: Command) {
if (opts.announce && typeof opts.deliver === "boolean") {
throw new Error("Choose --announce or --no-deliver (not multiple).");
}
const staggerRaw = typeof opts.stagger === "string" ? opts.stagger.trim() : "";
const useExact = Boolean(opts.exact);
if (staggerRaw && useExact) {
throw new Error("Choose either --stagger or --exact, not both");
}
const requestedStaggerMs = parseCronStaggerMs({ staggerRaw, useExact });
const patch: Record<string, unknown> = {};
if (typeof opts.name === "string") {
patch.name = opts.name;
@@ -154,36 +145,17 @@ export function registerCronEditCommand(cron: Command) {
patch.sessionKey = null;
}
const scheduleChosen = [opts.at, opts.every, opts.cron].filter(Boolean).length;
if (scheduleChosen > 1) {
throw new Error("Choose at most one schedule change");
}
if (
(requestedStaggerMs !== undefined || typeof opts.tz === "string") &&
(opts.at || opts.every)
) {
throw new Error("--stagger/--exact/--tz are only valid for cron schedules");
}
if (opts.at) {
const atIso = parseAt(String(opts.at));
if (!atIso) {
throw new Error("Invalid --at");
}
patch.schedule = { kind: "at", at: atIso };
} else if (opts.every) {
const everyMs = parseDurationMs(String(opts.every));
if (!everyMs) {
throw new Error("Invalid --every");
}
patch.schedule = { kind: "every", everyMs };
} else if (opts.cron) {
patch.schedule = {
kind: "cron",
expr: String(opts.cron),
tz: typeof opts.tz === "string" && opts.tz.trim() ? opts.tz.trim() : undefined,
staggerMs: requestedStaggerMs,
};
} else if (requestedStaggerMs !== undefined || typeof opts.tz === "string") {
const scheduleRequest = resolveCronEditScheduleRequest({
at: opts.at,
cron: opts.cron,
every: opts.every,
exact: opts.exact,
stagger: opts.stagger,
tz: opts.tz,
});
if (scheduleRequest.kind === "direct") {
patch.schedule = scheduleRequest.schedule;
} else if (scheduleRequest.kind === "patch-existing-cron") {
const listed = (await callGatewayFromCli("cron.list", opts, {
includeDisabled: true,
})) as { jobs?: CronJob[] } | null;
@@ -191,18 +163,7 @@ export function registerCronEditCommand(cron: Command) {
if (!existing) {
throw new Error(`unknown cron job id: ${id}`);
}
if (existing.schedule.kind !== "cron") {
throw new Error("Current job is not a cron schedule; use --cron to convert first");
}
const tz =
typeof opts.tz === "string" ? opts.tz.trim() || undefined : existing.schedule.tz;
patch.schedule = {
kind: "cron",
expr: existing.schedule.expr,
tz,
staggerMs:
requestedStaggerMs !== undefined ? requestedStaggerMs : existing.schedule.staggerMs,
};
patch.schedule = applyExistingCronSchedulePatch(existing.schedule, scheduleRequest);
}
const hasSystemEventPatch = typeof opts.systemEvent === "string";
+135
View File
@@ -0,0 +1,135 @@
import type { CronSchedule } from "../../cron/types.js";
import { parseAt, parseCronStaggerMs, parseDurationMs } from "./shared.js";
type ScheduleOptionInput = {
at?: unknown;
cron?: unknown;
every?: unknown;
exact?: unknown;
stagger?: unknown;
tz?: unknown;
};
type NormalizedScheduleOptions = {
at: string;
cronExpr: string;
every: string;
requestedStaggerMs: number | undefined;
tz: string | undefined;
};
export type CronEditScheduleRequest =
| { kind: "direct"; schedule: CronSchedule }
| { kind: "patch-existing-cron"; staggerMs: number | undefined; tz: string | undefined }
| { kind: "none" };
export function resolveCronCreateSchedule(options: ScheduleOptionInput): CronSchedule {
const normalized = normalizeScheduleOptions(options);
const chosen = countChosenSchedules(normalized);
if (chosen !== 1) {
throw new Error("Choose exactly one schedule: --at, --every, or --cron");
}
const schedule = resolveDirectSchedule(normalized);
if (!schedule) {
throw new Error("Choose exactly one schedule: --at, --every, or --cron");
}
return schedule;
}
export function resolveCronEditScheduleRequest(
options: ScheduleOptionInput,
): CronEditScheduleRequest {
const normalized = normalizeScheduleOptions(options);
const chosen = countChosenSchedules(normalized);
if (chosen > 1) {
throw new Error("Choose at most one schedule change");
}
const schedule = resolveDirectSchedule(normalized);
if (schedule) {
return { kind: "direct", schedule };
}
if (normalized.requestedStaggerMs !== undefined || normalized.tz !== undefined) {
return {
kind: "patch-existing-cron",
tz: normalized.tz,
staggerMs: normalized.requestedStaggerMs,
};
}
return { kind: "none" };
}
export function applyExistingCronSchedulePatch(
existingSchedule: CronSchedule,
request: Extract<CronEditScheduleRequest, { kind: "patch-existing-cron" }>,
): CronSchedule {
if (existingSchedule.kind !== "cron") {
throw new Error("Current job is not a cron schedule; use --cron to convert first");
}
return {
kind: "cron",
expr: existingSchedule.expr,
tz: request.tz ?? existingSchedule.tz,
staggerMs: request.staggerMs !== undefined ? request.staggerMs : existingSchedule.staggerMs,
};
}
function normalizeScheduleOptions(options: ScheduleOptionInput): NormalizedScheduleOptions {
const staggerRaw = readTrimmedString(options.stagger);
const useExact = Boolean(options.exact);
if (staggerRaw && useExact) {
throw new Error("Choose either --stagger or --exact, not both");
}
return {
at: readTrimmedString(options.at),
every: readTrimmedString(options.every),
cronExpr: readTrimmedString(options.cron),
tz: readOptionalString(options.tz),
requestedStaggerMs: parseCronStaggerMs({ staggerRaw, useExact }),
};
}
function countChosenSchedules(options: NormalizedScheduleOptions): number {
return [Boolean(options.at), Boolean(options.every), Boolean(options.cronExpr)].filter(Boolean)
.length;
}
function resolveDirectSchedule(options: NormalizedScheduleOptions): CronSchedule | undefined {
if (options.tz && options.every) {
throw new Error("--tz is only valid with --cron or offset-less --at");
}
if (options.requestedStaggerMs !== undefined && (options.at || options.every)) {
throw new Error("--stagger/--exact are only valid for cron schedules");
}
if (options.at) {
const atIso = parseAt(options.at, options.tz);
if (!atIso) {
throw new Error("Invalid --at; use ISO time or duration like 20m");
}
return { kind: "at", at: atIso };
}
if (options.every) {
const everyMs = parseDurationMs(options.every);
if (!everyMs) {
throw new Error("Invalid --every; use e.g. 10m, 1h, 1d");
}
return { kind: "every", everyMs };
}
if (options.cronExpr) {
return {
kind: "cron",
expr: options.cronExpr,
tz: options.tz,
staggerMs: options.requestedStaggerMs,
};
}
return undefined;
}
function readOptionalString(value: unknown): string | undefined {
const trimmed = readTrimmedString(value);
return trimmed || undefined;
}
function readTrimmedString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
+22 -1
View File
@@ -4,6 +4,10 @@ import { resolveCronStaggerMs } from "../../cron/stagger.js";
import type { CronJob, CronSchedule } from "../../cron/types.js";
import { danger } from "../../globals.js";
import { formatDurationHuman } from "../../infra/format-time/format-duration.ts";
import {
isOffsetlessIsoDateTime,
parseOffsetlessIsoDateTimeInTimeZone,
} from "../../infra/format-time/parse-offsetless-zoned-datetime.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
@@ -89,11 +93,28 @@ export function parseCronStaggerMs(params: {
return parsed;
}
export function parseAt(input: string): string | null {
/**
* Parse a one-shot `--at` value into an ISO string (UTC).
*
* When `tz` is provided and the input is an offset-less datetime
* (e.g. `2026-03-23T23:00:00`), the datetime is interpreted in
* that IANA timezone instead of UTC.
*/
export function parseAt(input: string, tz?: string): string | null {
const raw = input.trim();
if (!raw) {
return null;
}
// If a timezone is provided and the input looks like an offset-less ISO datetime,
// resolve it in the given IANA timezone so users get the time they expect.
if (tz && isOffsetlessIsoDateTime(raw)) {
const resolved = parseOffsetlessIsoDateTimeInTimeZone(raw, tz);
if (resolved) {
return resolved;
}
}
const absolute = parseAbsoluteTimeMs(raw);
if (absolute !== null) {
return new Date(absolute).toISOString();
+3 -3
View File
@@ -145,12 +145,12 @@ export function scanExecSafeBinTrustedDirHints(
for (const scope of collectExecSafeBinScopes(cfg)) {
for (const bin of scope.safeBins) {
const resolution = resolveCommandResolutionFromArgv([bin]);
if (!resolution?.resolvedPath) {
if (!resolution?.execution.resolvedPath) {
continue;
}
if (
isTrustedSafeBinPath({
resolvedPath: resolution.resolvedPath,
resolvedPath: resolution.execution.resolvedPath,
trustedDirs: scope.trustedSafeBinDirs,
})
) {
@@ -159,7 +159,7 @@ export function scanExecSafeBinTrustedDirHints(
hits.push({
scopePath: scope.scopePath,
bin,
resolvedPath: resolution.resolvedPath,
resolvedPath: resolution.execution.resolvedPath,
});
}
}
+74
View File
@@ -2,7 +2,9 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { VERSION } from "../version.js";
import { createConfigIO } from "./io.js";
import { parseOpenClawVersion } from "./version.js";
async function withTempHome(run: (home: string) => Promise<void>): Promise<void> {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-"));
@@ -157,4 +159,76 @@ describe("config io paths", () => {
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("- gateway.port:"));
});
});
it("does not warn when config was last touched by a same-base correction publish", async () => {
const parsedVersion = parseOpenClawVersion(VERSION);
if (!parsedVersion) {
throw new Error(`Unable to parse VERSION: ${VERSION}`);
}
const touchedVersion = `${parsedVersion.major}.${parsedVersion.minor}.${parsedVersion.patch}-${(parsedVersion.revision ?? 0) + 1}`;
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, "openclaw.json");
await fs.writeFile(
configPath,
JSON.stringify({ meta: { lastTouchedVersion: touchedVersion } }, null, 2),
);
const logger = {
warn: vi.fn(),
error: vi.fn(),
};
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
logger,
});
io.loadConfig();
expect(logger.warn).not.toHaveBeenCalledWith(
expect.stringContaining("Config was last written by a newer OpenClaw"),
);
expect(io.configPath).toBe(configPath);
});
});
it("does not warn for same-base prerelease configs when current version is newer", async () => {
const parsedVersion = parseOpenClawVersion(VERSION);
if (!parsedVersion) {
throw new Error(`Unable to parse VERSION: ${VERSION}`);
}
const touchedVersion = `${parsedVersion.major}.${parsedVersion.minor}.${parsedVersion.patch}-beta.1`;
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, "openclaw.json");
await fs.writeFile(
configPath,
JSON.stringify({ meta: { lastTouchedVersion: touchedVersion } }, null, 2),
);
const logger = {
warn: vi.fn(),
error: vi.fn(),
};
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
logger,
});
io.loadConfig();
expect(logger.warn).not.toHaveBeenCalledWith(
expect.stringContaining("Config was last written by a newer OpenClaw"),
);
expect(io.configPath).toBe(configPath);
});
});
});
+2 -6
View File
@@ -53,7 +53,7 @@ import {
validateConfigObjectRawWithPlugins,
validateConfigObjectWithPlugins,
} from "./validation.js";
import { compareOpenClawVersions } from "./version.js";
import { shouldWarnOnTouchedVersion } from "./version.js";
// Re-export for backwards compatibility
export { CircularIncludeError, ConfigIncludeError } from "./includes.js";
@@ -622,11 +622,7 @@ function warnIfConfigFromFuture(cfg: OpenClawConfig, logger: Pick<typeof console
if (!touched) {
return;
}
const cmp = compareOpenClawVersions(VERSION, touched);
if (cmp === null) {
return;
}
if (cmp < 0) {
if (shouldWarnOnTouchedVersion(VERSION, touched)) {
logger.warn(
`Config was last written by a newer OpenClaw (${touched}); current version is ${VERSION}.`,
);
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import {
compareOpenClawVersions,
isSameOpenClawStableFamily,
parseOpenClawVersion,
shouldWarnOnTouchedVersion,
} from "./version.js";
describe("parseOpenClawVersion", () => {
it("parses stable, correction, and beta forms", () => {
expect(parseOpenClawVersion("2026.3.23")).toEqual({
major: 2026,
minor: 3,
patch: 23,
revision: null,
prerelease: null,
});
expect(parseOpenClawVersion("2026.3.23-1")).toEqual({
major: 2026,
minor: 3,
patch: 23,
revision: 1,
prerelease: null,
});
expect(parseOpenClawVersion("2026.3.23-beta.1")).toEqual({
major: 2026,
minor: 3,
patch: 23,
revision: null,
prerelease: ["beta", "1"],
});
expect(parseOpenClawVersion("v2026.3.23.beta.2")).toEqual({
major: 2026,
minor: 3,
patch: 23,
revision: null,
prerelease: ["beta", "2"],
});
});
it("rejects invalid versions", () => {
expect(parseOpenClawVersion("2026.3")).toBeNull();
expect(parseOpenClawVersion("latest")).toBeNull();
});
});
describe("compareOpenClawVersions", () => {
it("treats correction publishes as newer than the base stable release", () => {
expect(compareOpenClawVersions("2026.3.23", "2026.3.23-1")).toBe(-1);
expect(compareOpenClawVersions("2026.3.23-1", "2026.3.23")).toBe(1);
expect(compareOpenClawVersions("2026.3.23-2", "2026.3.23-1")).toBe(1);
});
it("treats stable as newer than beta and compares beta identifiers", () => {
expect(compareOpenClawVersions("2026.3.23", "2026.3.23-beta.1")).toBe(1);
expect(compareOpenClawVersions("2026.3.23-beta.2", "2026.3.23-beta.1")).toBe(1);
expect(compareOpenClawVersions("2026.3.23.beta.1", "2026.3.23-beta.2")).toBe(-1);
});
});
describe("isSameOpenClawStableFamily", () => {
it("treats same-base stable and correction versions as one family", () => {
expect(isSameOpenClawStableFamily("2026.3.23", "2026.3.23-1")).toBe(true);
expect(isSameOpenClawStableFamily("2026.3.23-1", "2026.3.23-2")).toBe(true);
expect(isSameOpenClawStableFamily("2026.3.23", "2026.3.24")).toBe(false);
expect(isSameOpenClawStableFamily("2026.3.23-beta.1", "2026.3.23")).toBe(false);
});
});
describe("shouldWarnOnTouchedVersion", () => {
it("skips same-base stable families", () => {
expect(shouldWarnOnTouchedVersion("2026.3.23", "2026.3.23-1")).toBe(false);
expect(shouldWarnOnTouchedVersion("2026.3.23-1", "2026.3.23-2")).toBe(false);
});
it("skips same-base prerelease configs when current is newer", () => {
expect(shouldWarnOnTouchedVersion("2026.3.23", "2026.3.23-beta.1")).toBe(false);
});
it("warns when the touched config is newer", () => {
expect(shouldWarnOnTouchedVersion("2026.3.23-beta.1", "2026.3.23")).toBe(true);
expect(shouldWarnOnTouchedVersion("2026.3.23", "2026.3.24")).toBe(true);
expect(shouldWarnOnTouchedVersion("2026.3.23", "2027.1.1")).toBe(true);
});
});
+130 -6
View File
@@ -2,28 +2,59 @@ export type OpenClawVersion = {
major: number;
minor: number;
patch: number;
revision: number;
revision: number | null;
prerelease: string[] | null;
};
const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-(\d+))?/;
const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
export function parseOpenClawVersion(raw: string | null | undefined): OpenClawVersion | null {
if (!raw) {
return null;
}
const match = raw.trim().match(VERSION_RE);
const normalized = normalizeLegacyDotBetaVersion(raw.trim());
const match = normalized.match(VERSION_RE);
if (!match) {
return null;
}
const [, major, minor, patch, revision] = match;
const [, major, minor, patch, suffix] = match;
const revision = suffix && /^[0-9]+$/.test(suffix) ? Number.parseInt(suffix, 10) : null;
return {
major: Number.parseInt(major, 10),
minor: Number.parseInt(minor, 10),
patch: Number.parseInt(patch, 10),
revision: revision ? Number.parseInt(revision, 10) : 0,
revision,
prerelease: suffix && revision == null ? suffix.split(".").filter(Boolean) : null,
};
}
export function normalizeOpenClawVersionBase(raw: string | null | undefined): string | null {
const parsed = parseOpenClawVersion(raw);
if (!parsed) {
return null;
}
return `${parsed.major}.${parsed.minor}.${parsed.patch}`;
}
export function isSameOpenClawStableFamily(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
const parsedA = parseOpenClawVersion(a);
const parsedB = parseOpenClawVersion(b);
if (!parsedA || !parsedB) {
return false;
}
if (parsedA.prerelease?.length || parsedB.prerelease?.length) {
return false;
}
return (
parsedA.major === parsedB.major &&
parsedA.minor === parsedB.minor &&
parsedA.patch === parsedB.patch
);
}
export function compareOpenClawVersions(
a: string | null | undefined,
b: string | null | undefined,
@@ -42,8 +73,101 @@ export function compareOpenClawVersions(
if (parsedA.patch !== parsedB.patch) {
return parsedA.patch < parsedB.patch ? -1 : 1;
}
if (parsedA.revision !== parsedB.revision) {
const rankA = releaseRank(parsedA);
const rankB = releaseRank(parsedB);
if (rankA !== rankB) {
return rankA < rankB ? -1 : 1;
}
if (
parsedA.revision != null &&
parsedB.revision != null &&
parsedA.revision !== parsedB.revision
) {
return parsedA.revision < parsedB.revision ? -1 : 1;
}
if (parsedA.prerelease || parsedB.prerelease) {
return comparePrerelease(parsedA.prerelease, parsedB.prerelease);
}
return 0;
}
export function shouldWarnOnTouchedVersion(
current: string | null | undefined,
touched: string | null | undefined,
): boolean {
if (isSameOpenClawStableFamily(current, touched)) {
return false;
}
const cmp = compareOpenClawVersions(current, touched);
return cmp !== null && cmp < 0;
}
function normalizeLegacyDotBetaVersion(version: string): string {
const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(version);
if (!dotBetaMatch) {
return version;
}
const base = dotBetaMatch[1];
const suffix = dotBetaMatch[2];
return suffix ? `${base}-beta.${suffix}` : `${base}-beta`;
}
function releaseRank(version: OpenClawVersion): number {
if (version.prerelease?.length) {
return 0;
}
if (version.revision != null) {
return 2;
}
return 1;
}
function comparePrerelease(a: string[] | null, b: string[] | null): number {
if (!a?.length && !b?.length) {
return 0;
}
if (!a?.length) {
return 1;
}
if (!b?.length) {
return -1;
}
const max = Math.max(a.length, b.length);
for (let i = 0; i < max; i += 1) {
const ai = a[i];
const bi = b[i];
if (ai == null && bi == null) {
return 0;
}
if (ai == null) {
return -1;
}
if (bi == null) {
return 1;
}
if (ai === bi) {
continue;
}
const aiNumeric = /^[0-9]+$/.test(ai);
const biNumeric = /^[0-9]+$/.test(bi);
if (aiNumeric && biNumeric) {
const aiNum = Number.parseInt(ai, 10);
const biNum = Number.parseInt(bi, 10);
return aiNum < biNum ? -1 : 1;
}
if (aiNumeric && !biNumeric) {
return -1;
}
if (!aiNumeric && biNumeric) {
return 1;
}
return ai < bi ? -1 : 1;
}
return 0;
}
@@ -1,7 +1,11 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createNoopLogger, createCronStoreHarness } from "./service.test-harness.js";
import {
createNoopLogger,
createCronStoreHarness,
withCronServiceStateForTest,
} from "./service.test-harness.js";
import { createCronServiceState } from "./service/state.js";
import { onTimer } from "./service/timer.js";
import { resetReaperThrottle } from "./session-reaper.js";
@@ -72,14 +76,16 @@ describe("CronService - session reaper runs in finally block (#31946)", () => {
sessionStorePath,
});
await onTimer(state);
await withCronServiceStateForTest(state, async () => {
await onTimer(state);
// After onTimer finishes (even with a job error), state.running must be
// false — proving the finally block executed.
expect(state.running).toBe(false);
// After onTimer finishes (even with a job error), state.running must be
// false — proving the finally block executed.
expect(state.running).toBe(false);
// The timer must be re-armed.
expect(state.timer).not.toBeNull();
// The timer must be re-armed.
expect(state.timer).not.toBeNull();
});
});
it("session reaper runs when resolveSessionStorePath is provided", async () => {
@@ -112,12 +118,14 @@ describe("CronService - session reaper runs in finally block (#31946)", () => {
},
});
await onTimer(state);
await withCronServiceStateForTest(state, async () => {
await onTimer(state);
// The resolveSessionStorePath callback should have been invoked to build
// the set of store paths for the session reaper.
expect(resolvedPaths.length).toBeGreaterThan(0);
expect(state.running).toBe(false);
// The resolveSessionStorePath callback should have been invoked to build
// the set of store paths for the session reaper.
expect(resolvedPaths.length).toBeGreaterThan(0);
expect(state.running).toBe(false);
});
});
it("prunes expired cron-run sessions even when cron store load throws", async () => {
@@ -153,13 +161,14 @@ describe("CronService - session reaper runs in finally block (#31946)", () => {
sessionStorePath,
});
await expect(onTimer(state)).rejects.toThrow("Failed to parse cron store");
await withCronServiceStateForTest(state, async () => {
await expect(onTimer(state)).rejects.toThrow("Failed to parse cron store");
const updatedSessionStore = JSON.parse(await fs.readFile(sessionStorePath, "utf-8")) as Record<
string,
unknown
>;
expect(updatedSessionStore).toEqual({});
expect(state.running).toBe(false);
const updatedSessionStore = JSON.parse(
await fs.readFile(sessionStorePath, "utf-8"),
) as Record<string, unknown>;
expect(updatedSessionStore).toEqual({});
expect(state.running).toBe(false);
});
});
});
+18
View File
@@ -204,6 +204,24 @@ export function createRunningCronServiceState(params: {
return state;
}
export function disposeCronServiceState(state: { timer: NodeJS.Timeout | null }): void {
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
}
export async function withCronServiceStateForTest<T>(
state: { timer: NodeJS.Timeout | null },
run: () => Promise<T>,
): Promise<T> {
try {
return await run();
} finally {
disposeCronServiceState(state);
}
}
export function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
+64 -1
View File
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { buildControlUiCspHeader } from "./control-ui-csp.js";
import { buildControlUiCspHeader, computeInlineScriptHashes } from "./control-ui-csp.js";
describe("buildControlUiCspHeader", () => {
it("blocks inline scripts while allowing inline styles", () => {
@@ -15,4 +16,66 @@ describe("buildControlUiCspHeader", () => {
expect(csp).toContain("https://fonts.googleapis.com");
expect(csp).toContain("font-src 'self' https://fonts.gstatic.com");
});
it("includes inline script hashes in script-src when provided", () => {
const csp = buildControlUiCspHeader({
inlineScriptHashes: ["sha256-abc123"],
});
expect(csp).toContain("script-src 'self' 'sha256-abc123'");
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/);
});
it("includes multiple inline script hashes", () => {
const csp = buildControlUiCspHeader({
inlineScriptHashes: ["sha256-aaa", "sha256-bbb"],
});
expect(csp).toContain("script-src 'self' 'sha256-aaa' 'sha256-bbb'");
});
it("falls back to plain script-src self when hashes array is empty", () => {
const csp = buildControlUiCspHeader({ inlineScriptHashes: [] });
expect(csp).toMatch(/script-src 'self'(?:;|$)/);
});
});
describe("computeInlineScriptHashes", () => {
it("returns empty for HTML without scripts", () => {
expect(computeInlineScriptHashes("<html><body>hi</body></html>")).toEqual([]);
});
it("hashes inline script content", () => {
const content = "alert(1)";
const expected = createHash("sha256").update(content, "utf8").digest("base64");
const hashes = computeInlineScriptHashes(`<html><script>${content}</script></html>`);
expect(hashes).toEqual([`sha256-${expected}`]);
});
it("skips scripts with src attribute", () => {
const hashes = computeInlineScriptHashes('<html><script src="/app.js"></script></html>');
expect(hashes).toEqual([]);
});
it("hashes only inline scripts when mixed with external", () => {
const inlineContent = "console.log('init')";
const expected = createHash("sha256").update(inlineContent, "utf8").digest("base64");
const html = [
"<html><head>",
`<script>${inlineContent}</script>`,
'<script type="module" src="/app.js"></script>',
"</head></html>",
].join("");
const hashes = computeInlineScriptHashes(html);
expect(hashes).toEqual([`sha256-${expected}`]);
});
it("handles multiline inline scripts", () => {
const content = "\n var x = 1;\n console.log(x);\n";
const expected = createHash("sha256").update(content, "utf8").digest("base64");
const hashes = computeInlineScriptHashes(`<script>${content}</script>`);
expect(hashes).toEqual([`sha256-${expected}`]);
});
it("skips empty inline scripts", () => {
expect(computeInlineScriptHashes("<script></script>")).toEqual([]);
});
});
+31 -6
View File
@@ -1,14 +1,39 @@
export function buildControlUiCspHeader(): string {
// Control UI: block framing, block inline scripts, keep styles permissive
// (UI uses a lot of inline style attributes in templates).
// Keep Google Fonts origins explicit in CSP for deployments that load
// external Google Fonts stylesheets/font files.
import { createHash } from "node:crypto";
/**
* Compute SHA-256 CSP hashes for inline `<script>` blocks in an HTML string.
* Only scripts without a `src` attribute are considered inline.
*/
export function computeInlineScriptHashes(html: string): string[] {
const hashes: string[] = [];
const re = /<script(?:\s[^>]*)?>([^]*?)<\/script>/gi;
let match: RegExpExecArray | null;
while ((match = re.exec(html)) !== null) {
const openTag = match[0].slice(0, match[0].indexOf(">") + 1);
if (/\bsrc\s*=/i.test(openTag)) {
continue;
}
const content = match[1];
if (!content) {
continue;
}
const hash = createHash("sha256").update(content, "utf8").digest("base64");
hashes.push(`sha256-${hash}`);
}
return hashes;
}
export function buildControlUiCspHeader(opts?: { inlineScriptHashes?: string[] }): string {
const hashes = opts?.inlineScriptHashes;
const scriptSrc = hashes?.length
? `script-src 'self' ${hashes.map((h) => `'${h}'`).join(" ")}`
: "script-src 'self'";
return [
"default-src 'self'",
"base-uri 'none'",
"object-src 'none'",
"frame-ancestors 'none'",
"script-src 'self'",
scriptSrc,
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: https:",
"font-src 'self' https://fonts.gstatic.com",
+22
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import type { IncomingMessage } from "node:http";
import os from "node:os";
@@ -131,6 +132,27 @@ describe("handleControlUiHttpRequest", () => {
});
});
it("includes CSP hash for inline scripts in index.html", async () => {
const scriptContent = "(function(){ var x = 1; })();";
const html = `<html><head><script>${scriptContent}</script></head><body></body></html>\n`;
const expectedHash = createHash("sha256").update(scriptContent, "utf8").digest("base64");
await withControlUiRoot({
indexHtml: html,
fn: async (tmp) => {
const { res, setHeader } = makeMockHttpResponse();
handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, {
root: { kind: "resolved", path: tmp },
});
const cspCalls = setHeader.mock.calls.filter(
(call) => call[0] === "Content-Security-Policy",
);
const lastCsp = String(cspCalls[cspCalls.length - 1]?.[1] ?? "");
expect(lastCsp).toContain(`'sha256-${expectedHash}'`);
expect(lastCsp).not.toMatch(/script-src[^;]*'unsafe-inline'/);
},
});
});
it("does not inject inline scripts into index.html", async () => {
const html = "<html><head></head><body>Hello</body></html>\n";
await withControlUiRoot({
+8 -1
View File
@@ -16,7 +16,7 @@ import {
CONTROL_UI_BOOTSTRAP_CONFIG_PATH,
type ControlUiBootstrapConfig,
} from "./control-ui-contract.js";
import { buildControlUiCspHeader } from "./control-ui-csp.js";
import { buildControlUiCspHeader, computeInlineScriptHashes } from "./control-ui-csp.js";
import {
isReadHttpMethod,
respondNotFound as respondControlUiNotFound,
@@ -234,6 +234,13 @@ function serveResolvedFile(res: ServerResponse, filePath: string, body: Buffer)
}
function serveResolvedIndexHtml(res: ServerResponse, body: string) {
const hashes = computeInlineScriptHashes(body);
if (hashes.length > 0) {
res.setHeader(
"Content-Security-Policy",
buildControlUiCspHeader({ inlineScriptHashes: hashes }),
);
}
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.end(body);
+2
View File
@@ -77,6 +77,8 @@ describe("clawhub helpers", () => {
expect(satisfiesPluginApiRange("1.9.0", ">=1.2.0 <2.0.0")).toBe(true);
expect(satisfiesPluginApiRange("2.0.0", "^1.2.0")).toBe(false);
expect(satisfiesPluginApiRange("1.1.9", ">=1.2.0")).toBe(false);
expect(satisfiesPluginApiRange("2026.3.22", ">=2026.3.22")).toBe(true);
expect(satisfiesPluginApiRange("2026.3.21", ">=2026.3.22")).toBe(false);
expect(satisfiesPluginApiRange("invalid", "^1.2.0")).toBe(false);
});
+7 -10
View File
@@ -9,6 +9,11 @@ const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
export type ClawHubPackageFamily = "skill" | "code-plugin" | "bundle-plugin";
export type ClawHubPackageChannel = "official" | "community" | "private";
export type ClawHubPackageCompatibility = {
pluginApiRange?: string;
builtWithOpenClawVersion?: string;
minGatewayVersion?: string;
};
export type ClawHubPackageListItem = {
name: string;
@@ -31,11 +36,7 @@ export type ClawHubPackageDetail = {
package:
| (ClawHubPackageListItem & {
tags?: Record<string, string>;
compatibility?: {
pluginApiRange?: string;
builtWithOpenClawVersion?: string;
minGatewayVersion?: string;
} | null;
compatibility?: ClawHubPackageCompatibility | null;
capabilities?: {
executesCode?: boolean;
runtimeId?: string;
@@ -78,11 +79,7 @@ export type ClawHubPackageVersion = {
changelog: string;
distTags?: string[];
files?: unknown;
compatibility?: ClawHubPackageDetail["package"] extends infer T
? T extends { compatibility?: infer C }
? C
: never
: never;
compatibility?: ClawHubPackageCompatibility | null;
capabilities?: ClawHubPackageDetail["package"] extends infer T
? T extends { capabilities?: infer C }
? C
+76 -47
View File
@@ -1,7 +1,12 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js";
import {
makeMockCommandResolution,
makeMockExecutableResolution,
makePathEnv,
makeTempDir,
} from "./exec-approvals-test-helpers.js";
import {
evaluateShellAllowlist,
requiresExecApproval,
@@ -122,7 +127,13 @@ describe("resolveAllowAlwaysPatterns", () => {
{
raw: exe,
argv: [exe],
resolution: { rawExecutable: exe, resolvedPath: exe, executableName: "openclaw-tool" },
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: exe,
resolvedPath: exe,
executableName: "openclaw-tool",
}),
}),
},
],
});
@@ -140,11 +151,13 @@ describe("resolveAllowAlwaysPatterns", () => {
{
raw: "/bin/zsh -lc 'whoami'",
argv: ["/bin/zsh", "-lc", "whoami"],
resolution: {
rawExecutable: "/bin/zsh",
resolvedPath: "/bin/zsh",
executableName: "zsh",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/bin/zsh",
resolvedPath: "/bin/zsh",
executableName: "zsh",
}),
}),
},
],
cwd: dir,
@@ -167,11 +180,13 @@ describe("resolveAllowAlwaysPatterns", () => {
{
raw: "/bin/zsh -lc 'whoami && ls && whoami'",
argv: ["/bin/zsh", "-lc", "whoami && ls && whoami"],
resolution: {
rawExecutable: "/bin/zsh",
resolvedPath: "/bin/zsh",
executableName: "zsh",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/bin/zsh",
resolvedPath: "/bin/zsh",
executableName: "zsh",
}),
}),
},
],
cwd: dir,
@@ -400,11 +415,13 @@ $0 \\"$1\\"" touch ${marker}`,
{
raw: "/bin/zsh -s",
argv: ["/bin/zsh", "-s"],
resolution: {
rawExecutable: "/bin/zsh",
resolvedPath: "/bin/zsh",
executableName: "zsh",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/bin/zsh",
resolvedPath: "/bin/zsh",
executableName: "zsh",
}),
}),
},
],
platform: process.platform,
@@ -423,11 +440,13 @@ $0 \\"$1\\"" touch ${marker}`,
{
raw: "/usr/local/bin/zsh -lc whoami",
argv: ["/usr/local/bin/zsh", "-lc", "whoami"],
resolution: {
rawExecutable: "/usr/local/bin/zsh",
resolvedPath: undefined,
executableName: "/usr/local/bin/zsh",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/usr/local/bin/zsh",
resolvedPath: undefined,
executableName: "/usr/local/bin/zsh",
}),
}),
},
],
cwd: dir,
@@ -448,11 +467,13 @@ $0 \\"$1\\"" touch ${marker}`,
{
raw: "/usr/bin/nice /bin/zsh -lc whoami",
argv: ["/usr/bin/nice", "/bin/zsh", "-lc", "whoami"],
resolution: {
rawExecutable: "/usr/bin/nice",
resolvedPath: "/usr/bin/nice",
executableName: "nice",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/usr/bin/nice",
resolvedPath: "/usr/bin/nice",
executableName: "nice",
}),
}),
},
],
cwd: dir,
@@ -474,11 +495,13 @@ $0 \\"$1\\"" touch ${marker}`,
{
raw: "/usr/bin/time -p /bin/zsh -lc whoami",
argv: ["/usr/bin/time", "-p", "/bin/zsh", "-lc", "whoami"],
resolution: {
rawExecutable: "/usr/bin/time",
resolvedPath: "/usr/bin/time",
executableName: "time",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/usr/bin/time",
resolvedPath: "/usr/bin/time",
executableName: "time",
}),
}),
},
],
cwd: dir,
@@ -503,11 +526,13 @@ $0 \\"$1\\"" touch ${marker}`,
{
raw: `${busybox} sh -lc whoami`,
argv: [busybox, "sh", "-lc", "whoami"],
resolution: {
rawExecutable: busybox,
resolvedPath: busybox,
executableName: "busybox",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: busybox,
resolvedPath: busybox,
executableName: "busybox",
}),
}),
},
],
cwd: dir,
@@ -529,11 +554,13 @@ $0 \\"$1\\"" touch ${marker}`,
{
raw: `${busybox} sed -n 1p`,
argv: [busybox, "sed", "-n", "1p"],
resolution: {
rawExecutable: busybox,
resolvedPath: busybox,
executableName: "busybox",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: busybox,
resolvedPath: busybox,
executableName: "busybox",
}),
}),
},
],
cwd: dir,
@@ -549,11 +576,13 @@ $0 \\"$1\\"" touch ${marker}`,
{
raw: "sudo /bin/zsh -lc whoami",
argv: ["sudo", "/bin/zsh", "-lc", "whoami"],
resolution: {
rawExecutable: "sudo",
resolvedPath: "/usr/bin/sudo",
executableName: "sudo",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "sudo",
resolvedPath: "/usr/bin/sudo",
executableName: "sudo",
}),
}),
},
],
platform: process.platform,
+21 -19
View File
@@ -3,12 +3,15 @@ import {
analyzeShellCommand,
isWindowsPlatform,
matchAllowlist,
resolveAllowlistCandidatePath,
resolveExecutionTargetCandidatePath,
resolveExecutionTargetResolution,
resolveCommandResolutionFromArgv,
resolvePolicyTargetCandidatePath,
resolvePolicyTargetResolution,
splitCommandChain,
type ExecCommandAnalysis,
type CommandResolution,
type ExecCommandSegment,
type ExecutableResolution,
} from "./exec-approvals-analysis.js";
import type { ExecAllowlistEntry } from "./exec-approvals.js";
import {
@@ -50,7 +53,7 @@ export function resolveSafeBins(entries?: readonly string[] | null): Set<string>
export function isSafeBinUsage(params: {
argv: string[];
resolution: CommandResolution | null;
resolution: ExecutableResolution | null;
safeBins: Set<string>;
platform?: string | null;
trustedSafeBinDirs?: ReadonlySet<string>;
@@ -182,15 +185,16 @@ function isSkillAutoAllowedSegment(params: {
return false;
}
const resolution = params.segment.resolution;
if (!resolution?.resolvedPath) {
const execution = resolveExecutionTargetResolution(resolution);
if (!execution?.resolvedPath) {
return false;
}
const rawExecutable = resolution.rawExecutable?.trim() ?? "";
const rawExecutable = execution.rawExecutable?.trim() ?? "";
if (!rawExecutable || isPathScopedExecutableToken(rawExecutable)) {
return false;
}
const executableName = normalizeSkillBinName(resolution.executableName);
const resolvedPath = normalizeSkillBinResolvedPath(resolution.resolvedPath);
const executableName = normalizeSkillBinName(execution.executableName);
const resolvedPath = normalizeSkillBinResolvedPath(execution.resolvedPath);
if (!executableName || !resolvedPath) {
return false;
}
@@ -221,11 +225,12 @@ function evaluateSegments(
: segment.argv;
const allowlistSegment =
effectiveArgv === segment.argv ? segment : { ...segment, argv: effectiveArgv };
const candidatePath = resolveAllowlistCandidatePath(segment.resolution, params.cwd);
const executableResolution = resolvePolicyTargetResolution(segment.resolution);
const candidatePath = resolvePolicyTargetCandidatePath(segment.resolution, params.cwd);
const candidateResolution =
candidatePath && segment.resolution
? { ...segment.resolution, resolvedPath: candidatePath }
: segment.resolution;
candidatePath && executableResolution
? { ...executableResolution, resolvedPath: candidatePath }
: executableResolution;
const executableMatch = matchAllowlist(params.allowlist, candidateResolution);
const inlineCommand = extractShellWrapperInlineCommand(allowlistSegment.argv);
const shellPositionalArgvCandidatePath = resolveShellWrapperPositionalArgvCandidatePath({
@@ -260,7 +265,7 @@ function evaluateSegments(
}
const safe = isSafeBinUsage({
argv: effectiveArgv,
resolution: segment.resolution,
resolution: resolveExecutionTargetResolution(segment.resolution),
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
platform: params.platform,
@@ -335,11 +340,8 @@ function hasSegmentExecutableMatch(
segment: ExecCommandSegment,
predicate: (token: string) => boolean,
): boolean {
const candidates = [
segment.resolution?.executableName,
segment.resolution?.rawExecutable,
segment.argv[0],
];
const execution = resolveExecutionTargetResolution(segment.resolution);
const candidates = [execution?.executableName, execution?.rawExecutable, segment.argv[0]];
for (const candidate of candidates) {
const trimmed = candidate?.trim();
if (!trimmed) {
@@ -462,7 +464,7 @@ function resolveShellWrapperPositionalArgvCandidatePath(params: {
}
const resolution = resolveCommandResolutionFromArgv([carriedExecutable], params.cwd, params.env);
return resolveAllowlistCandidatePath(resolution, params.cwd);
return resolveExecutionTargetCandidatePath(resolution, params.cwd);
}
function isDirectShellPositionalCarrierInvocation(command: string): boolean {
@@ -507,7 +509,7 @@ function collectAllowAlwaysPatterns(params: {
resolution: resolveCommandResolutionFromArgv(trustPlan.argv, params.cwd, params.env),
};
const candidatePath = resolveAllowlistCandidatePath(segment.resolution, params.cwd);
const candidatePath = resolveExecutionTargetCandidatePath(segment.resolution, params.cwd);
if (!candidatePath) {
return;
}
+31
View File
@@ -7,6 +7,7 @@ import {
analyzeShellCommand,
buildEnforcedShellCommand,
buildSafeBinsShellCommand,
resolvePlannedSegmentArgv,
} from "./exec-approvals-analysis.js";
import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js";
import type { ExecAllowlistEntry } from "./exec-approvals.js";
@@ -70,6 +71,36 @@ describe("exec approvals shell analysis", () => {
expect(res.command).toMatch(/'(?:[^']*\/)?rg' '-n' 'needle'/);
expect(res.command).not.toContain("'env'");
});
it("keeps shell multiplexer rebuilds as coherent execution argv", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const busybox = path.join(dir, "busybox");
fs.writeFileSync(busybox, "");
fs.chmodSync(busybox, 0o755);
const analysis = analyzeArgvCommand({
argv: [busybox, "sh", "-lc", "echo hi"],
cwd: dir,
env: { PATH: `/bin:/usr/bin${path.delimiter}${process.env.PATH ?? ""}` },
});
expect(analysis.ok).toBe(true);
const segment = analysis.segments[0];
if (!segment) {
throw new Error("expected first segment");
}
const planned = resolvePlannedSegmentArgv(segment);
expect(planned).toEqual([
segment.resolution?.execution.resolvedRealPath ??
segment.resolution?.execution.resolvedPath,
"-lc",
"echo hi",
]);
expect(planned?.[0]).not.toBe(busybox);
});
});
describe("shell parsing", () => {
+9 -1
View File
@@ -9,9 +9,16 @@ export {
matchAllowlist,
parseExecArgvToken,
resolveAllowlistCandidatePath,
resolveApprovalAuditCandidatePath,
resolveCommandResolution,
resolveCommandResolutionFromArgv,
resolveExecutionTargetCandidatePath,
resolveExecutionTargetResolution,
resolvePolicyAllowlistCandidatePath,
resolvePolicyTargetCandidatePath,
resolvePolicyTargetResolution,
type CommandResolution,
type ExecutableResolution,
type ExecArgvToken,
} from "./exec-command-resolution.js";
@@ -665,8 +672,9 @@ export function resolvePlannedSegmentArgv(segment: ExecCommandSegment): string[]
return null;
}
const argv = [...baseArgv];
const execution = segment.resolution?.execution;
const resolvedExecutable =
segment.resolution?.resolvedRealPath?.trim() ?? segment.resolution?.resolvedPath?.trim() ?? "";
execution?.resolvedRealPath?.trim() ?? execution?.resolvedPath?.trim() ?? "";
if (resolvedExecutable) {
argv[0] = resolvedExecutable;
}
+1 -1
View File
@@ -31,7 +31,7 @@ describe("exec approvals wrapper resolution parity fixture", () => {
for (const fixture of fixtures) {
it(`matches wrapper fixture: ${fixture.id}`, () => {
const resolution = resolveCommandResolutionFromArgv(fixture.argv);
expect(resolution?.rawExecutable ?? null).toBe(fixture.expectedRawExecutable);
expect(resolution?.execution.rawExecutable ?? null).toBe(fixture.expectedRawExecutable);
});
}
});
+14 -7
View File
@@ -1,7 +1,12 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js";
import {
makeMockCommandResolution,
makeMockExecutableResolution,
makePathEnv,
makeTempDir,
} from "./exec-approvals-test-helpers.js";
import {
evaluateExecAllowlist,
evaluateShellAllowlist,
@@ -428,11 +433,13 @@ describe("exec approvals safe bins", () => {
{
raw: "jq .foo",
argv: ["jq", ".foo"],
resolution: {
rawExecutable: "jq",
resolvedPath: "/custom/bin/jq",
executableName: "jq",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "jq",
resolvedPath: "/custom/bin/jq",
executableName: "jq",
}),
}),
},
],
};
@@ -476,7 +483,7 @@ describe("exec approvals safe bins", () => {
expect(result.analysisOk).toBe(true);
expect(result.allowlistSatisfied).toBe(false);
expect(result.segmentSatisfiedBy).toEqual([null]);
expect(result.segments[0]?.resolution?.resolvedPath).toBe(fakeHead);
expect(result.segments[0]?.resolution?.execution.resolvedPath).toBe(fakeHead);
});
it("fails closed for semantic env wrappers in allowlist mode", () => {
+51
View File
@@ -1,6 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { CommandResolution, ExecutableResolution } from "./exec-command-resolution.js";
export function makePathEnv(binDir: string): NodeJS.ProcessEnv {
if (process.platform !== "win32") {
@@ -13,6 +14,56 @@ export function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-approvals-"));
}
export function makeMockExecutableResolution(params: {
rawExecutable: string;
executableName: string;
resolvedPath?: string;
resolvedRealPath?: string;
}): ExecutableResolution {
return {
rawExecutable: params.rawExecutable,
resolvedPath: params.resolvedPath,
resolvedRealPath: params.resolvedRealPath,
executableName: params.executableName,
};
}
export function makeMockCommandResolution(params: {
execution: ExecutableResolution;
policy?: ExecutableResolution;
effectiveArgv?: string[];
wrapperChain?: string[];
policyBlocked?: boolean;
blockedWrapper?: string;
}): CommandResolution {
const policy = params.policy ?? params.execution;
const resolution: CommandResolution = {
execution: params.execution,
policy,
effectiveArgv: params.effectiveArgv,
wrapperChain: params.wrapperChain,
policyBlocked: params.policyBlocked,
blockedWrapper: params.blockedWrapper,
};
return Object.defineProperties(resolution, {
rawExecutable: {
get: () => params.execution.rawExecutable,
},
resolvedPath: {
get: () => params.execution.resolvedPath,
},
resolvedRealPath: {
get: () => params.execution.resolvedRealPath,
},
executableName: {
get: () => params.execution.executableName,
},
policyResolution: {
get: () => (policy === params.execution ? undefined : policy),
},
});
}
export type ShellParserParityFixtureCase = {
id: string;
command: string;
+60 -44
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { normalizeSafeBins } from "./exec-approvals-allowlist.js";
import {
makeMockCommandResolution,
makeMockExecutableResolution,
} from "./exec-approvals-test-helpers.js";
import { evaluateExecAllowlist, type ExecAllowlistEntry } from "./exec-approvals.js";
describe("exec approvals allowlist evaluation", () => {
@@ -9,11 +13,7 @@ describe("exec approvals allowlist evaluation", () => {
segments: Array<{
raw: string;
argv: string[];
resolution: {
rawExecutable: string;
executableName: string;
resolvedPath?: string;
};
resolution: ReturnType<typeof makeMockCommandResolution>;
}>;
};
resolvedPath: string;
@@ -40,11 +40,13 @@ describe("exec approvals allowlist evaluation", () => {
{
raw: "tool",
argv: ["tool"],
resolution: {
rawExecutable: "tool",
resolvedPath: "/usr/bin/tool",
executableName: "tool",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "tool",
resolvedPath: "/usr/bin/tool",
executableName: "tool",
}),
}),
},
],
};
@@ -66,11 +68,13 @@ describe("exec approvals allowlist evaluation", () => {
{
raw: "jq .foo",
argv: ["jq", ".foo"],
resolution: {
rawExecutable: "jq",
resolvedPath: "/usr/bin/jq",
executableName: "jq",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "jq",
resolvedPath: "/usr/bin/jq",
executableName: "jq",
}),
}),
},
],
};
@@ -96,11 +100,13 @@ describe("exec approvals allowlist evaluation", () => {
{
raw: "skill-bin",
argv: ["skill-bin", "--help"],
resolution: {
rawExecutable: "skill-bin",
resolvedPath: "/opt/skills/skill-bin",
executableName: "skill-bin",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "skill-bin",
resolvedPath: "/opt/skills/skill-bin",
executableName: "skill-bin",
}),
}),
},
],
};
@@ -118,11 +124,13 @@ describe("exec approvals allowlist evaluation", () => {
{
raw: "./skill-bin",
argv: ["./skill-bin", "--help"],
resolution: {
rawExecutable: "./skill-bin",
resolvedPath: "/tmp/skill-bin",
executableName: "skill-bin",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "./skill-bin",
resolvedPath: "/tmp/skill-bin",
executableName: "skill-bin",
}),
}),
},
],
};
@@ -140,10 +148,12 @@ describe("exec approvals allowlist evaluation", () => {
{
raw: "skill-bin --help",
argv: ["skill-bin", "--help"],
resolution: {
rawExecutable: "skill-bin",
executableName: "skill-bin",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "skill-bin",
executableName: "skill-bin",
}),
}),
},
],
};
@@ -158,11 +168,13 @@ describe("exec approvals allowlist evaluation", () => {
const segment = {
raw: "tool",
argv: ["tool"],
resolution: {
rawExecutable: "tool",
resolvedPath: "/usr/bin/tool",
executableName: "tool",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "tool",
resolvedPath: "/usr/bin/tool",
executableName: "tool",
}),
}),
};
const analysis = {
ok: true,
@@ -184,20 +196,24 @@ describe("exec approvals allowlist evaluation", () => {
const allowlistSegment = {
raw: "tool",
argv: ["tool"],
resolution: {
rawExecutable: "tool",
resolvedPath: "/usr/bin/tool",
executableName: "tool",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "tool",
resolvedPath: "/usr/bin/tool",
executableName: "tool",
}),
}),
};
const safeBinSegment = {
raw: "jq .foo",
argv: ["jq", ".foo"],
resolution: {
rawExecutable: "jq",
resolvedPath: "/usr/bin/jq",
executableName: "jq",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "jq",
resolvedPath: "/usr/bin/jq",
executableName: "jq",
}),
}),
};
const analysis = {
ok: true,
+149 -16
View File
@@ -4,11 +4,13 @@ import { describe, expect, it } from "vitest";
import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js";
import {
evaluateExecAllowlist,
resolvePlannedSegmentArgv,
normalizeSafeBins,
parseExecArgvToken,
resolveAllowlistCandidatePath,
resolveCommandResolution,
resolveCommandResolutionFromArgv,
resolveExecutionTargetCandidatePath,
resolvePolicyTargetCandidatePath,
} from "./exec-approvals.js";
function buildNestedEnvShellCommand(params: {
@@ -118,9 +120,9 @@ describe("exec-command-resolution", () => {
for (const testCase of cases) {
const setup = testCase.setup();
const res = resolveCommandResolution(setup.command, setup.cwd, setup.envPath);
expect(res?.resolvedPath, testCase.name).toBe(setup.expectedPath);
expect(res?.execution.resolvedPath, testCase.name).toBe(setup.expectedPath);
if (setup.expectedExecutableName) {
expect(res?.executableName, testCase.name).toBe(setup.expectedExecutableName);
expect(res?.execution.executableName, testCase.name).toBe(setup.expectedExecutableName);
}
}
});
@@ -133,8 +135,8 @@ describe("exec-command-resolution", () => {
undefined,
makePathEnv(fixture.binDir),
);
expect(envResolution?.resolvedPath).toBe(fixture.exePath);
expect(envResolution?.executableName).toBe(fixture.exeName);
expect(envResolution?.execution.resolvedPath).toBe(fixture.exePath);
expect(envResolution?.execution.executableName).toBe(fixture.exeName);
const niceResolution = resolveCommandResolutionFromArgv([
"/usr/bin/nice",
@@ -142,19 +144,19 @@ describe("exec-command-resolution", () => {
"-lc",
"echo hi",
]);
expect(niceResolution?.rawExecutable).toBe("bash");
expect(niceResolution?.executableName.toLowerCase()).toContain("bash");
expect(niceResolution?.execution.rawExecutable).toBe("bash");
expect(niceResolution?.execution.executableName.toLowerCase()).toContain("bash");
const timeResolution = resolveCommandResolutionFromArgv(
["/usr/bin/time", "-p", "rg", "-n", "needle"],
undefined,
makePathEnv(fixture.binDir),
);
expect(timeResolution?.resolvedPath).toBe(fixture.exePath);
expect(timeResolution?.executableName).toBe(fixture.exeName);
expect(timeResolution?.execution.resolvedPath).toBe(fixture.exePath);
expect(timeResolution?.execution.executableName).toBe(fixture.exeName);
});
it("unwraps shell multiplexers before resolving the effective executable", () => {
it("keeps shell multiplexer wrappers as a separate policy target", () => {
if (process.platform === "win32") {
return;
}
@@ -164,9 +166,45 @@ describe("exec-command-resolution", () => {
fs.chmodSync(busybox, 0o755);
const resolution = resolveCommandResolutionFromArgv([busybox, "sh", "-lc", "echo hi"]);
expect(resolution?.rawExecutable).toBe("sh");
expect(resolution?.execution.rawExecutable).toBe("sh");
expect(resolution?.effectiveArgv).toEqual(["sh", "-lc", "echo hi"]);
expect(resolution?.wrapperChain).toEqual(["busybox"]);
expect(resolution?.executableName.toLowerCase()).toContain("sh");
expect(resolution?.policy.rawExecutable).toBe(busybox);
expect(resolution?.policy.resolvedPath).toBe(busybox);
expect(resolvePolicyTargetCandidatePath(resolution ?? null, dir)).toBe(busybox);
expect(resolution?.execution.executableName.toLowerCase()).toContain("sh");
});
it("does not satisfy inner-shell allowlists when invoked through busybox wrappers", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const busybox = path.join(dir, "busybox");
fs.writeFileSync(busybox, "");
fs.chmodSync(busybox, 0o755);
const shellResolution = resolveCommandResolutionFromArgv(["sh", "-lc", "echo hi"]);
expect(shellResolution?.execution.resolvedPath).toBeTruthy();
const wrappedResolution = resolveCommandResolutionFromArgv([busybox, "sh", "-lc", "echo hi"]);
const evalResult = evaluateExecAllowlist({
analysis: {
ok: true,
segments: [
{
raw: `${busybox} sh -lc echo hi`,
argv: [busybox, "sh", "-lc", "echo hi"],
resolution: wrappedResolution,
},
],
},
allowlist: [{ pattern: shellResolution?.execution.resolvedPath ?? "" }],
safeBins: normalizeSafeBins([]),
cwd: dir,
});
expect(evalResult.allowlistSatisfied).toBe(false);
});
it("blocks semantic env wrappers, env -S, and deep transparent-wrapper chains", () => {
@@ -178,7 +216,7 @@ describe("exec-command-resolution", () => {
"needle",
]);
expect(blockedEnv?.policyBlocked).toBe(true);
expect(blockedEnv?.rawExecutable).toBe("/usr/bin/env");
expect(blockedEnv?.execution.rawExecutable).toBe("/usr/bin/env");
if (process.platform === "win32") {
return;
@@ -215,7 +253,7 @@ describe("exec-command-resolution", () => {
it("resolves allowlist candidate paths from unresolved raw executables", () => {
expect(
resolveAllowlistCandidatePath(
resolveExecutionTargetCandidatePath(
{
rawExecutable: "~/bin/tool",
executableName: "tool",
@@ -225,7 +263,7 @@ describe("exec-command-resolution", () => {
).toContain("/bin/tool");
expect(
resolveAllowlistCandidatePath(
resolveExecutionTargetCandidatePath(
{
rawExecutable: "./scripts/run.sh",
executableName: "run.sh",
@@ -235,7 +273,7 @@ describe("exec-command-resolution", () => {
).toBe(path.resolve("/repo", "./scripts/run.sh"));
expect(
resolveAllowlistCandidatePath(
resolveExecutionTargetCandidatePath(
{
rawExecutable: "rg",
executableName: "rg",
@@ -245,6 +283,101 @@ describe("exec-command-resolution", () => {
).toBeUndefined();
});
it("keeps execution and policy targets coherent across wrapper classes", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const binDir = path.join(dir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const envPath = path.join(binDir, "env");
const rgPath = path.join(binDir, "rg");
const busybox = path.join(dir, "busybox");
for (const file of [envPath, rgPath, busybox]) {
fs.writeFileSync(file, "");
fs.chmodSync(file, 0o755);
}
const cases = [
{
name: "transparent env wrapper",
argv: [envPath, "rg", "-n", "needle"],
env: makePathEnv(binDir),
expectedExecutionPath: rgPath,
expectedPolicyPath: rgPath,
expectedPlannedArgv: [fs.realpathSync(rgPath), "-n", "needle"],
allowlistPattern: rgPath,
allowlistSatisfied: true,
},
{
name: "busybox shell multiplexer",
argv: [busybox, "sh", "-lc", "echo hi"],
env: { PATH: `${binDir}${path.delimiter}/bin:/usr/bin` },
expectedExecutionPath: "/bin/sh",
expectedPolicyPath: busybox,
expectedPlannedArgv: ["/bin/sh", "-lc", "echo hi"],
allowlistPattern: busybox,
allowlistSatisfied: true,
},
{
name: "semantic env wrapper",
argv: [envPath, "FOO=bar", "rg", "-n", "needle"],
env: makePathEnv(binDir),
expectedExecutionPath: envPath,
expectedPolicyPath: envPath,
expectedPlannedArgv: null,
allowlistPattern: envPath,
allowlistSatisfied: false,
},
{
name: "wrapper depth overflow",
argv: buildNestedEnvShellCommand({
envExecutable: envPath,
depth: 5,
payload: "echo hi",
}),
env: makePathEnv(binDir),
expectedExecutionPath: envPath,
expectedPolicyPath: envPath,
expectedPlannedArgv: null,
allowlistPattern: envPath,
allowlistSatisfied: false,
},
] as const;
for (const testCase of cases) {
const argv = [...testCase.argv];
const resolution = resolveCommandResolutionFromArgv(argv, dir, testCase.env);
const segment = {
raw: argv.join(" "),
argv,
resolution,
};
expect(
resolveExecutionTargetCandidatePath(resolution ?? null, dir),
`${testCase.name} execution`,
).toBe(testCase.expectedExecutionPath);
expect(
resolvePolicyTargetCandidatePath(resolution ?? null, dir),
`${testCase.name} policy`,
).toBe(testCase.expectedPolicyPath);
expect(resolvePlannedSegmentArgv(segment), `${testCase.name} planned argv`).toEqual(
testCase.expectedPlannedArgv,
);
const evaluation = evaluateExecAllowlist({
analysis: { ok: true, segments: [segment] },
allowlist: [{ pattern: testCase.allowlistPattern }],
safeBins: normalizeSafeBins([]),
cwd: dir,
env: testCase.env,
});
expect(evaluation.allowlistSatisfied, `${testCase.name} allowlist`).toBe(
testCase.allowlistSatisfied,
);
}
});
it("normalizes argv tokens for short clusters, long options, and special sentinels", () => {
expect(parseExecArgvToken("")).toEqual({ kind: "empty", raw: "" });
expect(parseExecArgvToken("--")).toEqual({ kind: "terminator", raw: "--" });
+123 -15
View File
@@ -6,17 +6,28 @@ import { resolveExecWrapperTrustPlan } from "./exec-wrapper-trust-plan.js";
import { resolveExecutablePath as resolveExecutableCandidatePath } from "./executable-path.js";
import { expandHomePrefix } from "./home-dir.js";
export type CommandResolution = {
export type ExecutableResolution = {
rawExecutable: string;
resolvedPath?: string;
resolvedRealPath?: string;
executableName: string;
};
export type CommandResolution = {
execution: ExecutableResolution;
policy: ExecutableResolution;
effectiveArgv?: string[];
wrapperChain?: string[];
policyBlocked?: boolean;
blockedWrapper?: string;
};
function isCommandResolution(
resolution: CommandResolution | ExecutableResolution | null,
): resolution is CommandResolution {
return Boolean(resolution && "execution" in resolution && "policy" in resolution);
}
function parseFirstToken(command: string): string | null {
const trimmed = command.trim();
if (!trimmed) {
@@ -45,8 +56,30 @@ function tryResolveRealpath(filePath: string | undefined): string | undefined {
}
}
function buildExecutableResolution(
rawExecutable: string,
params: {
cwd?: string;
env?: NodeJS.ProcessEnv;
},
): ExecutableResolution {
const resolvedPath = resolveExecutableCandidatePath(rawExecutable, {
cwd: params.cwd,
env: params.env,
});
const resolvedRealPath = tryResolveRealpath(resolvedPath);
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
return {
rawExecutable,
resolvedPath,
resolvedRealPath,
executableName,
};
}
function buildCommandResolution(params: {
rawExecutable: string;
policyRawExecutable?: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
effectiveArgv: string[];
@@ -54,22 +87,36 @@ function buildCommandResolution(params: {
policyBlocked: boolean;
blockedWrapper?: string;
}): CommandResolution {
const resolvedPath = resolveExecutableCandidatePath(params.rawExecutable, {
cwd: params.cwd,
env: params.env,
});
const resolvedRealPath = tryResolveRealpath(resolvedPath);
const executableName = resolvedPath ? path.basename(resolvedPath) : params.rawExecutable;
return {
rawExecutable: params.rawExecutable,
resolvedPath,
resolvedRealPath,
executableName,
const execution = buildExecutableResolution(params.rawExecutable, params);
const policy = params.policyRawExecutable
? buildExecutableResolution(params.policyRawExecutable, params)
: execution;
const resolution: CommandResolution = {
execution,
policy,
effectiveArgv: params.effectiveArgv,
wrapperChain: params.wrapperChain,
policyBlocked: params.policyBlocked,
blockedWrapper: params.blockedWrapper,
};
// Compatibility getters for JS/tests while TS callers migrate to explicit targets.
return Object.defineProperties(resolution, {
rawExecutable: {
get: () => execution.rawExecutable,
},
resolvedPath: {
get: () => execution.resolvedPath,
},
resolvedRealPath: {
get: () => execution.resolvedRealPath,
},
executableName: {
get: () => execution.executableName,
},
policyResolution: {
get: () => (policy === execution ? undefined : policy),
},
});
}
export function resolveCommandResolution(
@@ -104,6 +151,7 @@ export function resolveCommandResolutionFromArgv(
}
return buildCommandResolution({
rawExecutable,
policyRawExecutable: plan.policyArgv[0]?.trim(),
effectiveArgv,
wrapperChain: plan.wrapperChain,
policyBlocked: plan.policyBlocked,
@@ -113,8 +161,8 @@ export function resolveCommandResolutionFromArgv(
});
}
export function resolveAllowlistCandidatePath(
resolution: CommandResolution | null,
function resolveExecutableCandidatePathFromResolution(
resolution: ExecutableResolution | null | undefined,
cwd?: string,
): string | undefined {
if (!resolution) {
@@ -138,9 +186,69 @@ export function resolveAllowlistCandidatePath(
return path.resolve(base, expanded);
}
export function resolveExecutionTargetResolution(
resolution: CommandResolution | ExecutableResolution | null,
): ExecutableResolution | null {
if (!resolution) {
return null;
}
return isCommandResolution(resolution) ? resolution.execution : resolution;
}
export function resolvePolicyTargetResolution(
resolution: CommandResolution | ExecutableResolution | null,
): ExecutableResolution | null {
if (!resolution) {
return null;
}
return isCommandResolution(resolution) ? resolution.policy : resolution;
}
export function resolveExecutionTargetCandidatePath(
resolution: CommandResolution | ExecutableResolution | null,
cwd?: string,
): string | undefined {
return resolveExecutableCandidatePathFromResolution(
isCommandResolution(resolution) ? resolution.execution : resolution,
cwd,
);
}
export function resolvePolicyTargetCandidatePath(
resolution: CommandResolution | ExecutableResolution | null,
cwd?: string,
): string | undefined {
return resolveExecutableCandidatePathFromResolution(
isCommandResolution(resolution) ? resolution.policy : resolution,
cwd,
);
}
export function resolveApprovalAuditCandidatePath(
resolution: CommandResolution | null,
cwd?: string,
): string | undefined {
return resolvePolicyTargetCandidatePath(resolution, cwd);
}
// Legacy alias kept while callers migrate to explicit target naming.
export function resolveAllowlistCandidatePath(
resolution: CommandResolution | ExecutableResolution | null,
cwd?: string,
): string | undefined {
return resolveExecutionTargetCandidatePath(resolution, cwd);
}
export function resolvePolicyAllowlistCandidatePath(
resolution: CommandResolution | ExecutableResolution | null,
cwd?: string,
): string | undefined {
return resolvePolicyTargetCandidatePath(resolution, cwd);
}
export function matchAllowlist(
entries: ExecAllowlistEntry[],
resolution: CommandResolution | null,
resolution: ExecutableResolution | null,
): ExecAllowlistEntry | null {
if (!entries.length) {
return null;
+28
View File
@@ -10,6 +10,7 @@ describe("resolveExecWrapperTrustPlan", () => {
resolveExecWrapperTrustPlan(["/usr/bin/time", "-p", "busybox", "sh", "-lc", "echo hi"]),
).toEqual({
argv: ["sh", "-lc", "echo hi"],
policyArgv: ["busybox", "sh", "-lc", "echo hi"],
wrapperChain: ["time", "busybox"],
policyBlocked: false,
shellWrapperExecutable: true,
@@ -20,6 +21,7 @@ describe("resolveExecWrapperTrustPlan", () => {
test("fails closed for unsupported shell multiplexer applets", () => {
expect(resolveExecWrapperTrustPlan(["busybox", "sed", "-n", "1p"])).toEqual({
argv: ["busybox", "sed", "-n", "1p"],
policyArgv: ["busybox", "sed", "-n", "1p"],
wrapperChain: [],
policyBlocked: true,
blockedWrapper: "busybox",
@@ -33,6 +35,7 @@ describe("resolveExecWrapperTrustPlan", () => {
resolveExecWrapperTrustPlan(["nohup", "timeout", "5s", "busybox", "sh", "-lc", "echo hi"], 2),
).toEqual({
argv: ["busybox", "sh", "-lc", "echo hi"],
policyArgv: ["busybox", "sh", "-lc", "echo hi"],
wrapperChain: ["nohup", "timeout"],
policyBlocked: true,
blockedWrapper: "busybox",
@@ -40,4 +43,29 @@ describe("resolveExecWrapperTrustPlan", () => {
shellInlineCommand: null,
});
});
test("keeps the blocked dispatch argv as the policy target after transparent unwraps", () => {
if (process.platform === "win32") {
return;
}
expect(
resolveExecWrapperTrustPlan([
"/usr/bin/time",
"-p",
"/usr/bin/env",
"FOO=bar",
"sh",
"-lc",
"echo hi",
]),
).toEqual({
argv: ["/usr/bin/env", "FOO=bar", "sh", "-lc", "echo hi"],
policyArgv: ["/usr/bin/env", "FOO=bar", "sh", "-lc", "echo hi"],
wrapperChain: [],
policyBlocked: true,
blockedWrapper: "env",
shellWrapperExecutable: false,
shellInlineCommand: null,
});
});
});
+20 -1
View File
@@ -11,6 +11,7 @@ import {
export type ExecWrapperTrustPlan = {
argv: string[];
policyArgv: string[];
wrapperChain: string[];
policyBlocked: boolean;
blockedWrapper?: string;
@@ -20,11 +21,13 @@ export type ExecWrapperTrustPlan = {
function blockedExecWrapperTrustPlan(params: {
argv: string[];
policyArgv?: string[];
wrapperChain: string[];
blockedWrapper: string;
}): ExecWrapperTrustPlan {
return {
argv: params.argv,
policyArgv: params.policyArgv ?? params.argv,
wrapperChain: params.wrapperChain,
policyBlocked: true,
blockedWrapper: params.blockedWrapper,
@@ -35,6 +38,7 @@ function blockedExecWrapperTrustPlan(params: {
function finalizeExecWrapperTrustPlan(
argv: string[],
policyArgv: string[],
wrapperChain: string[],
policyBlocked: boolean,
blockedWrapper?: string,
@@ -44,6 +48,7 @@ function finalizeExecWrapperTrustPlan(
!policyBlocked && rawExecutable.length > 0 && isShellWrapperExecutable(rawExecutable);
return {
argv,
policyArgv,
wrapperChain,
policyBlocked,
blockedWrapper,
@@ -57,12 +62,15 @@ export function resolveExecWrapperTrustPlan(
maxDepth = MAX_DISPATCH_WRAPPER_DEPTH,
): ExecWrapperTrustPlan {
let current = argv;
let policyArgv = argv;
let sawShellMultiplexer = false;
const wrapperChain: string[] = [];
for (let depth = 0; depth < maxDepth; depth += 1) {
const dispatchPlan = resolveDispatchWrapperTrustPlan(current, maxDepth - wrapperChain.length);
if (dispatchPlan.policyBlocked) {
return blockedExecWrapperTrustPlan({
argv: dispatchPlan.argv,
policyArgv: dispatchPlan.argv,
wrapperChain,
blockedWrapper: dispatchPlan.blockedWrapper ?? current[0] ?? "unknown",
});
@@ -70,6 +78,9 @@ export function resolveExecWrapperTrustPlan(
if (dispatchPlan.wrappers.length > 0) {
wrapperChain.push(...dispatchPlan.wrappers);
current = dispatchPlan.argv;
if (!sawShellMultiplexer) {
policyArgv = current;
}
if (wrapperChain.length >= maxDepth) {
break;
}
@@ -80,12 +91,18 @@ export function resolveExecWrapperTrustPlan(
if (shellMultiplexerUnwrap.kind === "blocked") {
return blockedExecWrapperTrustPlan({
argv: current,
policyArgv,
wrapperChain,
blockedWrapper: shellMultiplexerUnwrap.wrapper,
});
}
if (shellMultiplexerUnwrap.kind === "unwrapped") {
wrapperChain.push(shellMultiplexerUnwrap.wrapper);
if (!sawShellMultiplexer) {
// Preserve the real executable target for trust checks.
policyArgv = current;
sawShellMultiplexer = true;
}
current = shellMultiplexerUnwrap.argv;
if (wrapperChain.length >= maxDepth) {
break;
@@ -101,6 +118,7 @@ export function resolveExecWrapperTrustPlan(
if (dispatchOverflow.kind === "blocked" || dispatchOverflow.kind === "unwrapped") {
return blockedExecWrapperTrustPlan({
argv: current,
policyArgv,
wrapperChain,
blockedWrapper: dispatchOverflow.wrapper,
});
@@ -112,11 +130,12 @@ export function resolveExecWrapperTrustPlan(
) {
return blockedExecWrapperTrustPlan({
argv: current,
policyArgv,
wrapperChain,
blockedWrapper: shellMultiplexerOverflow.wrapper,
});
}
}
return finalizeExecWrapperTrustPlan(current, wrapperChain, false);
return finalizeExecWrapperTrustPlan(current, policyArgv, wrapperChain, false);
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
isOffsetlessIsoDateTime,
parseOffsetlessIsoDateTimeInTimeZone,
} from "./parse-offsetless-zoned-datetime.js";
describe("parseOffsetlessIsoDateTimeInTimeZone", () => {
it("detects offset-less ISO datetimes", () => {
expect(isOffsetlessIsoDateTime("2026-03-23T23:00:00")).toBe(true);
expect(isOffsetlessIsoDateTime("2026-03-23T23:00:00+02:00")).toBe(false);
expect(isOffsetlessIsoDateTime("+20m")).toBe(false);
});
it("converts offset-less datetimes in the requested timezone", () => {
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00", "Europe/Oslo")).toBe(
"2026-03-23T22:00:00.000Z",
);
});
it("keeps DST boundary conversions on the intended wall-clock time", () => {
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-29T01:30:00", "Europe/Oslo")).toBe(
"2026-03-29T00:30:00.000Z",
);
});
it("returns null for invalid input", () => {
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00+02:00", "Europe/Oslo")).toBe(
null,
);
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00", "Invalid/Timezone")).toBe(
null,
);
});
});
@@ -0,0 +1,58 @@
const OFFSETLESS_ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?$/;
export function isOffsetlessIsoDateTime(raw: string): boolean {
return OFFSETLESS_ISO_DATETIME_RE.test(raw);
}
export function parseOffsetlessIsoDateTimeInTimeZone(raw: string, timeZone: string): string | null {
if (!isOffsetlessIsoDateTime(raw)) {
return null;
}
try {
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date());
const naiveMs = new Date(`${raw}Z`).getTime();
if (Number.isNaN(naiveMs)) {
return null;
}
// Re-check the offset at the first candidate instant so DST boundaries
// land on the intended wall-clock time instead of drifting by one hour.
const firstOffsetMs = getTimeZoneOffsetMs(naiveMs, timeZone);
const candidateMs = naiveMs - firstOffsetMs;
const finalOffsetMs = getTimeZoneOffsetMs(candidateMs, timeZone);
return new Date(naiveMs - finalOffsetMs).toISOString();
} catch {
return null;
}
}
function getTimeZoneOffsetMs(utcMs: number, timeZone: string): number {
const utcDate = new Date(utcMs);
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(utcDate);
const getNumericPart = (type: string) => {
const part = parts.find((candidate) => candidate.type === type);
return Number.parseInt(part?.value ?? "0", 10);
};
const localAsUtc = Date.UTC(
getNumericPart("year"),
getNumericPart("month") - 1,
getNumericPart("day"),
getNumericPart("hour"),
getNumericPart("minute"),
getNumericPart("second"),
);
return localAsUtc - utcMs;
}
+2 -1
View File
@@ -1004,7 +1004,8 @@ export function hardenApprovedExecutionPaths(params: {
};
}
const pinnedExecutable = resolution?.resolvedRealPath ?? resolution?.resolvedPath;
const pinnedExecutable =
resolution?.execution.resolvedRealPath ?? resolution?.execution.resolvedPath;
if (!pinnedExecutable) {
return {
ok: false,
+2 -1
View File
@@ -5,6 +5,7 @@ import type { GatewayClient } from "../gateway/client.js";
import {
addAllowlistEntry,
recordAllowlistUse,
resolveApprovalAuditCandidatePath,
resolveAllowAlwaysPatterns,
resolveExecApprovals,
type ExecAllowlistEntry,
@@ -575,7 +576,7 @@ async function executeSystemRunPhase(
phase.agentId,
match,
phase.commandText,
phase.segments[0]?.resolution?.resolvedPath,
resolveApprovalAuditCandidatePath(phase.segments[0]?.resolution ?? null, phase.cwd),
);
}
}
+31 -9
View File
@@ -1,3 +1,4 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { isPidAlive } from "../shared/pid-alive.js";
@@ -27,6 +28,34 @@ type HeldLock = {
const HELD_LOCKS_KEY = Symbol.for("openclaw.fileLockHeldLocks");
const HELD_LOCKS = resolveProcessScopedMap<HeldLock>(HELD_LOCKS_KEY);
const CLEANUP_REGISTERED_KEY = Symbol.for("openclaw.fileLockCleanupRegistered");
function releaseAllLocksSync(): void {
for (const [normalizedFile, held] of HELD_LOCKS) {
// Let the OS close live descriptors on process exit. On Linux/macOS this
// avoids Node's unmanaged-fd warnings while still unlinking the stale
// lock path before the process is fully gone.
rmLockPathSync(held.lockPath);
HELD_LOCKS.delete(normalizedFile);
}
}
function rmLockPathSync(lockPath: string): void {
try {
fsSync.rmSync(lockPath, { force: true });
} catch {
// Best-effort exit cleanup only.
}
}
function ensureExitCleanupRegistered(): void {
const proc = process as NodeJS.Process & { [CLEANUP_REGISTERED_KEY]?: boolean };
if (proc[CLEANUP_REGISTERED_KEY]) {
return;
}
proc[CLEANUP_REGISTERED_KEY] = true;
process.on("exit", releaseAllLocksSync);
}
function computeDelayMs(retries: FileLockOptions["retries"], attempt: number): number {
const base = Math.min(
@@ -101,15 +130,7 @@ async function releaseHeldLock(normalizedFile: string): Promise<void> {
}
export function resetFileLockStateForTest(): void {
for (const [normalizedFile, held] of HELD_LOCKS) {
try {
void held.handle.close().catch(() => undefined);
} catch {
// Best-effort test cleanup only.
}
void fs.rm(held.lockPath, { force: true }).catch(() => undefined);
HELD_LOCKS.delete(normalizedFile);
}
releaseAllLocksSync();
}
/** Acquire a re-entrant process-local file lock backed by a `.lock` sidecar file. */
@@ -117,6 +138,7 @@ export async function acquireFileLock(
filePath: string,
options: FileLockOptions,
): Promise<FileLockHandle> {
ensureExitCleanupRegistered();
const normalizedFile = await resolveNormalizedFilePath(filePath);
const lockPath = `${normalizedFile}.lock`;
const held = HELD_LOCKS.get(normalizedFile);
+20 -17
View File
@@ -5,8 +5,6 @@ const fetchClawHubPackageDetailMock = vi.fn();
const fetchClawHubPackageVersionMock = vi.fn();
const downloadClawHubPackageArchiveMock = vi.fn();
const resolveLatestVersionFromPackageMock = vi.fn();
const satisfiesPluginApiRangeMock = vi.fn();
const satisfiesGatewayMinimumMock = vi.fn();
const resolveRuntimeServiceVersionMock = vi.fn();
const installPluginFromArchiveMock = vi.fn();
@@ -21,8 +19,6 @@ vi.mock("../infra/clawhub.js", async () => {
downloadClawHubPackageArchiveMock(...args),
resolveLatestVersionFromPackage: (...args: unknown[]) =>
resolveLatestVersionFromPackageMock(...args),
satisfiesPluginApiRange: (...args: unknown[]) => satisfiesPluginApiRangeMock(...args),
satisfiesGatewayMinimum: (...args: unknown[]) => satisfiesGatewayMinimumMock(...args),
};
});
@@ -45,8 +41,6 @@ describe("installPluginFromClawHub", () => {
fetchClawHubPackageVersionMock.mockReset();
downloadClawHubPackageArchiveMock.mockReset();
resolveLatestVersionFromPackageMock.mockReset();
satisfiesPluginApiRangeMock.mockReset();
satisfiesGatewayMinimumMock.mockReset();
resolveRuntimeServiceVersionMock.mockReset();
installPluginFromArchiveMock.mockReset();
@@ -61,19 +55,19 @@ describe("installPluginFromClawHub", () => {
createdAt: 0,
updatedAt: 0,
compatibility: {
pluginApiRange: "^1.2.0",
pluginApiRange: ">=2026.3.22",
minGatewayVersion: "2026.3.0",
},
},
});
resolveLatestVersionFromPackageMock.mockReturnValue("1.2.3");
resolveLatestVersionFromPackageMock.mockReturnValue("2026.3.22");
fetchClawHubPackageVersionMock.mockResolvedValue({
version: {
version: "1.2.3",
version: "2026.3.22",
createdAt: 0,
changelog: "",
compatibility: {
pluginApiRange: "^1.2.0",
pluginApiRange: ">=2026.3.22",
minGatewayVersion: "2026.3.0",
},
},
@@ -82,14 +76,12 @@ describe("installPluginFromClawHub", () => {
archivePath: "/tmp/clawhub-demo/archive.zip",
integrity: "sha256-demo",
});
satisfiesPluginApiRangeMock.mockReturnValue(true);
resolveRuntimeServiceVersionMock.mockReturnValue("2026.3.22");
satisfiesGatewayMinimumMock.mockReturnValue(true);
installPluginFromArchiveMock.mockResolvedValue({
ok: true,
pluginId: "demo",
targetDir: "/tmp/openclaw/plugins/demo",
version: "1.2.3",
version: "2026.3.22",
});
});
@@ -116,7 +108,7 @@ describe("installPluginFromClawHub", () => {
expect(fetchClawHubPackageVersionMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "demo",
version: "1.2.3",
version: "2026.3.22",
}),
);
expect(installPluginFromArchiveMock).toHaveBeenCalledWith(
@@ -127,7 +119,7 @@ describe("installPluginFromClawHub", () => {
expect(result).toMatchObject({
ok: true,
pluginId: "demo",
version: "1.2.3",
version: "2026.3.22",
clawhub: {
source: "clawhub",
clawhubPackage: "demo",
@@ -136,11 +128,22 @@ describe("installPluginFromClawHub", () => {
integrity: "sha256-demo",
},
});
expect(info).toHaveBeenCalledWith("ClawHub code-plugin demo@1.2.3 channel=official");
expect(info).toHaveBeenCalledWith("Compatibility: pluginApi=^1.2.0 minGateway=2026.3.0");
expect(info).toHaveBeenCalledWith("ClawHub code-plugin demo@2026.3.22 channel=official");
expect(info).toHaveBeenCalledWith("Compatibility: pluginApi=>=2026.3.22 minGateway=2026.3.0");
expect(warn).not.toHaveBeenCalled();
});
it("rejects packages whose plugin API range exceeds the runtime version", async () => {
resolveRuntimeServiceVersionMock.mockReturnValueOnce("2026.3.21");
await expect(installPluginFromClawHub({ spec: "clawhub:demo" })).resolves.toMatchObject({
ok: false,
code: CLAWHUB_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API,
error:
'Plugin "demo" requires plugin API >=2026.3.22, but this OpenClaw runtime exposes 2026.3.21.',
});
});
it("rejects skill families and redirects to skills install", async () => {
fetchClawHubPackageDetailMock.mockResolvedValueOnce({
package: {
+16 -15
View File
@@ -10,13 +10,13 @@ import {
satisfiesGatewayMinimum,
satisfiesPluginApiRange,
type ClawHubPackageChannel,
type ClawHubPackageCompatibility,
type ClawHubPackageDetail,
type ClawHubPackageFamily,
} from "../infra/clawhub.js";
import { resolveRuntimeServiceVersion } from "../version.js";
import { installPluginFromArchive, type InstallPluginResult } from "./install.js";
export const OPENCLAW_PLUGIN_API_VERSION = "1.2.0";
export const CLAWHUB_INSTALL_ERROR_CODE = {
INVALID_SPEC: "invalid_spec",
PACKAGE_NOT_FOUND: "package_not_found",
@@ -104,10 +104,7 @@ async function resolveCompatiblePackageVersion(params: {
| {
ok: true;
version: string;
compatibility?: {
pluginApiRange?: string;
minGatewayVersion?: string;
} | null;
compatibility?: ClawHubPackageCompatibility | null;
}
| ClawHubInstallFailure
> {
@@ -143,10 +140,8 @@ async function resolveCompatiblePackageVersion(params: {
function validateClawHubPluginPackage(params: {
detail: ClawHubPackageDetail;
compatibility?: {
pluginApiRange?: string;
minGatewayVersion?: string;
} | null;
compatibility?: ClawHubPackageCompatibility | null;
runtimeVersion: string;
}): ClawHubInstallFailure | null {
const pkg = params.detail.package;
if (!pkg) {
@@ -175,17 +170,17 @@ function validateClawHubPluginPackage(params: {
}
const compatibility = params.compatibility;
const runtimeVersion = params.runtimeVersion;
if (
compatibility?.pluginApiRange &&
!satisfiesPluginApiRange(OPENCLAW_PLUGIN_API_VERSION, compatibility.pluginApiRange)
!satisfiesPluginApiRange(runtimeVersion, compatibility.pluginApiRange)
) {
return buildClawHubInstallFailure(
`Plugin "${pkg.name}" requires plugin API ${compatibility.pluginApiRange}, but this OpenClaw runtime exposes ${OPENCLAW_PLUGIN_API_VERSION}.`,
`Plugin "${pkg.name}" requires plugin API ${compatibility.pluginApiRange}, but this OpenClaw runtime exposes ${runtimeVersion}.`,
CLAWHUB_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API,
);
}
const runtimeVersion = resolveRuntimeServiceVersion();
if (
compatibility?.minGatewayVersion &&
!satisfiesGatewayMinimum(runtimeVersion, compatibility.minGatewayVersion)
@@ -201,6 +196,7 @@ function validateClawHubPluginPackage(params: {
function logClawHubPackageSummary(params: {
detail: ClawHubPackageDetail;
version: string;
compatibility?: ClawHubPackageCompatibility | null;
logger?: PluginInstallLogger;
}) {
const pkg = params.detail.package;
@@ -212,9 +208,11 @@ function logClawHubPackageSummary(params: {
`ClawHub ${pkg.family} ${pkg.name}@${params.version} channel=${pkg.channel}${verification}`,
);
const compatibilityParts = [
pkg.compatibility?.pluginApiRange ? `pluginApi=${pkg.compatibility.pluginApiRange}` : null,
pkg.compatibility?.minGatewayVersion
? `minGateway=${pkg.compatibility.minGatewayVersion}`
params.compatibility?.pluginApiRange
? `pluginApi=${params.compatibility.pluginApiRange}`
: null,
params.compatibility?.minGatewayVersion
? `minGateway=${params.compatibility.minGatewayVersion}`
: null,
].filter(Boolean);
if (compatibilityParts.length > 0) {
@@ -276,9 +274,11 @@ export async function installPluginFromClawHub(params: {
if (!versionState.ok) {
return versionState;
}
const runtimeVersion = resolveRuntimeServiceVersion();
const validationFailure = validateClawHubPluginPackage({
detail,
compatibility: versionState.compatibility,
runtimeVersion,
});
if (validationFailure) {
return validationFailure;
@@ -286,6 +286,7 @@ export async function installPluginFromClawHub(params: {
logClawHubPackageSummary({
detail,
version: versionState.version,
compatibility: versionState.compatibility,
logger: params.logger,
});
@@ -77,6 +77,35 @@ describe("stageBundledPluginRuntime", () => {
expect(runtimeModule.value).toBe(1);
});
it("stages root runtime sidecars that bundled plugin boundaries resolve directly", () => {
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-sidecars-");
const distPluginDir = path.join(repoRoot, "dist", "extensions", "whatsapp");
fs.mkdirSync(distPluginDir, { recursive: true });
fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {};\n", "utf8");
fs.writeFileSync(
path.join(distPluginDir, "light-runtime-api.js"),
"export const light = true;\n",
"utf8",
);
fs.writeFileSync(
path.join(distPluginDir, "runtime-api.js"),
"export const heavy = true;\n",
"utf8",
);
stageBundledPluginRuntime({ repoRoot });
const runtimePluginDir = path.join(repoRoot, "dist-runtime", "extensions", "whatsapp");
expect(fs.existsSync(path.join(runtimePluginDir, "light-runtime-api.js"))).toBe(true);
expect(fs.existsSync(path.join(runtimePluginDir, "runtime-api.js"))).toBe(true);
expect(fs.readFileSync(path.join(runtimePluginDir, "light-runtime-api.js"), "utf8")).toContain(
"../../../dist/extensions/whatsapp/light-runtime-api.js",
);
expect(fs.readFileSync(path.join(runtimePluginDir, "runtime-api.js"), "utf8")).toContain(
"../../../dist/extensions/whatsapp/runtime-api.js",
);
});
it("keeps plugin command registration on the canonical dist graph when loaded from dist-runtime", async () => {
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-commands-");
const distPluginDir = path.join(repoRoot, "dist", "extensions", "demo");
+57
View File
@@ -81,6 +81,63 @@ describe("buildPluginStatusReport", () => {
);
});
it("normalizes bundled plugin versions to the core base release", () => {
loadOpenClawPluginsMock.mockReturnValue({
plugins: [
{
id: "whatsapp",
name: "WhatsApp",
description: "Bundled channel plugin",
version: "2026.3.22",
source: "/tmp/whatsapp/index.ts",
origin: "bundled",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: ["whatsapp"],
providerIds: [],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
},
],
diagnostics: [],
channels: [],
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
webSearchProviders: [],
tools: [],
hooks: [],
typedHooks: [],
channelSetups: [],
httpRoutes: [],
gatewayHandlers: {},
cliRegistrars: [],
services: [],
commands: [],
});
const report = buildPluginStatusReport({
config: {},
env: {
OPENCLAW_VERSION: "2026.3.23-1",
} as NodeJS.ProcessEnv,
});
expect(report.plugins[0]?.version).toBe("2026.3.23");
});
it("builds an inspect report with capability shape and policy", () => {
loadConfigMock.mockReturnValue({
plugins: {
+20
View File
@@ -1,7 +1,9 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import { loadConfig } from "../config/config.js";
import { normalizeOpenClawVersionBase } from "../config/version.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveRuntimeServiceVersion } from "../version.js";
import { inspectBundleLspRuntimeSupport } from "./bundle-lsp.js";
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
import { normalizePluginsConfig } from "./config-state.js";
@@ -114,6 +116,20 @@ function buildCompatibilityNoticesForInspect(
const log = createSubsystemLogger("plugins");
function resolveReportedPluginVersion(
plugin: PluginRegistry["plugins"][number],
env: NodeJS.ProcessEnv | undefined,
): string | undefined {
if (plugin.origin !== "bundled") {
return plugin.version;
}
return (
normalizeOpenClawVersionBase(resolveRuntimeServiceVersion(env)) ??
normalizeOpenClawVersionBase(plugin.version) ??
plugin.version
);
}
export function buildPluginStatusReport(params?: {
config?: ReturnType<typeof loadConfig>;
workspaceDir?: string;
@@ -136,6 +152,10 @@ export function buildPluginStatusReport(params?: {
return {
workspaceDir,
...registry,
plugins: registry.plugins.map((plugin) => ({
...plugin,
version: resolveReportedPluginVersion(plugin, params?.env),
})),
};
}
+227
View File
@@ -0,0 +1,227 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import ts from "typescript";
const JITI_EXTENSIONS = [
".ts",
".tsx",
".mts",
".cts",
".mtsx",
".ctsx",
".js",
".mjs",
".cjs",
".json",
] as const;
const PLUGIN_SDK_SPECIFIER_PREFIX = "openclaw/plugin-sdk/";
const SOURCE_MODULE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"] as const;
type SourceModuleRef = {
specifier: string;
typeOnly: boolean;
};
function listPluginSdkExportedSubpaths(root: string): string[] {
const packageJsonPath = path.join(root, "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
exports?: Record<string, unknown>;
};
return Object.keys(packageJson.exports ?? {})
.filter((key) => key.startsWith("./plugin-sdk/"))
.map((key) => key.slice("./plugin-sdk/".length));
}
function resolvePluginSdkAliasTarget(root: string, subpath: string): string | null {
const distCandidate = path.join(root, "dist", "plugin-sdk", `${subpath}.js`);
if (existsSync(distCandidate)) {
return distCandidate;
}
for (const ext of SOURCE_MODULE_EXTENSIONS) {
const srcCandidate = path.join(root, "src", "plugin-sdk", `${subpath}${ext}`);
if (existsSync(srcCandidate)) {
return srcCandidate;
}
}
return null;
}
function resolveLocalModulePath(filePath: string, specifier: string): string | null {
const basePath = path.resolve(path.dirname(filePath), specifier);
const candidates = new Set<string>([basePath]);
for (const ext of SOURCE_MODULE_EXTENSIONS) {
candidates.add(`${basePath}${ext}`);
}
if (/\.[cm]?[jt]sx?$/u.test(basePath)) {
const withoutExt = basePath.replace(/\.[cm]?[jt]sx?$/u, "");
for (const ext of SOURCE_MODULE_EXTENSIONS) {
candidates.add(`${withoutExt}${ext}`);
}
}
for (const ext of SOURCE_MODULE_EXTENSIONS) {
candidates.add(path.join(basePath, `index${ext}`));
}
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
function collectSourceModuleRefs(filePath: string): SourceModuleRef[] {
const sourceText = readFileSync(filePath, "utf8");
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
const refs: SourceModuleRef[] = [];
for (const statement of sourceFile.statements) {
if (ts.isImportDeclaration(statement)) {
const specifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
if (specifier) {
refs.push({
specifier,
typeOnly: Boolean(statement.importClause?.isTypeOnly),
});
}
continue;
}
if (!ts.isExportDeclaration(statement)) {
continue;
}
const specifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
if (!specifier) {
continue;
}
const typeOnly = Boolean(
statement.isTypeOnly ||
(statement.exportClause &&
ts.isNamedExports(statement.exportClause) &&
statement.exportClause.elements.length > 0 &&
statement.exportClause.elements.every((element) => element.isTypeOnly)),
);
refs.push({ specifier, typeOnly });
}
return refs;
}
function collectPluginSdkAliases(params: {
modulePath: string;
root: string;
}): Record<string, string> {
const realSpecifiers = new Set<string>();
const stubSpecifiers = new Set<string>();
const visitedFiles = new Set<string>();
const stubPath = path.join(params.root, "test", "helpers", "extensions", "plugin-sdk-stub.cjs");
function visitModule(filePath: string, rootModule: boolean): void {
if (visitedFiles.has(filePath)) {
return;
}
visitedFiles.add(filePath);
for (const ref of collectSourceModuleRefs(filePath)) {
if (ref.specifier.startsWith(PLUGIN_SDK_SPECIFIER_PREFIX)) {
if (rootModule && !ref.typeOnly) {
realSpecifiers.add(ref.specifier);
const subpath = ref.specifier.slice(PLUGIN_SDK_SPECIFIER_PREFIX.length);
const target = resolvePluginSdkAliasTarget(params.root, subpath);
if (target?.endsWith(".ts")) {
visitModule(target, false);
}
} else {
stubSpecifiers.add(ref.specifier);
}
continue;
}
if (!ref.specifier.startsWith(".")) {
continue;
}
const resolved = resolveLocalModulePath(filePath, ref.specifier);
if (resolved) {
visitModule(resolved, false);
}
}
}
visitModule(params.modulePath, true);
const aliasEntries = new Map<string, string>();
for (const specifier of listPluginSdkExportedSubpaths(params.root).map(
(subpath) => `${PLUGIN_SDK_SPECIFIER_PREFIX}${subpath}`,
)) {
if (realSpecifiers.has(specifier)) {
const subpath = specifier.slice(PLUGIN_SDK_SPECIFIER_PREFIX.length);
aliasEntries.set(specifier, resolvePluginSdkAliasTarget(params.root, subpath) ?? stubPath);
continue;
}
if (stubSpecifiers.has(specifier)) {
aliasEntries.set(specifier, stubPath);
}
}
return Object.fromEntries(aliasEntries);
}
export function loadRuntimeApiExportTypesViaJiti(params: {
modulePath: string;
exportNames: readonly string[];
additionalAliases?: Record<string, string>;
}): Record<string, string> {
const root = process.cwd();
const alias = {
...collectPluginSdkAliases({ modulePath: params.modulePath, root }),
...params.additionalAliases,
};
const script = `
import path from "node:path";
import { createJiti } from "jiti";
const modulePath = ${JSON.stringify(params.modulePath)};
const exportNames = ${JSON.stringify(params.exportNames)};
const alias = ${JSON.stringify(alias)};
const jiti = createJiti(path.join(${JSON.stringify(root)}, "openclaw.mjs"), {
interopDefault: true,
tryNative: false,
fsCache: false,
moduleCache: false,
extensions: ${JSON.stringify(JITI_EXTENSIONS)},
alias,
});
const mod = jiti(modulePath);
console.log(
JSON.stringify(
Object.fromEntries(exportNames.map((name) => [name, typeof mod[name]])),
),
);
`;
const raw = execFileSync(process.execPath, ["--input-type=module", "--eval", script], {
cwd: root,
encoding: "utf-8",
});
return JSON.parse(raw) as Record<string, string>;
}
@@ -0,0 +1,65 @@
"use strict";
let stub;
stub = new Proxy(
function pluginSdkStub() {
return stub;
},
{
apply() {
return stub;
},
construct() {
return stub;
},
get(_target, prop) {
if (prop === "__esModule") {
return true;
}
if (prop === "default") {
return stub;
}
if (prop === "then") {
return undefined;
}
if (prop === Symbol.toPrimitive) {
return () => "";
}
if (prop === "toJSON") {
return () => undefined;
}
if (prop === "toString") {
return () => "";
}
if (prop === "valueOf") {
return () => 0;
}
return stub;
},
ownKeys() {
return [];
},
getOwnPropertyDescriptor(_target, prop) {
if (prop === "__esModule") {
return {
configurable: true,
enumerable: false,
value: true,
writable: false,
};
}
if (prop === "default") {
return {
configurable: true,
enumerable: false,
value: stub,
writable: false,
};
}
return undefined;
},
},
);
module.exports = stub;
+28 -3
View File
@@ -14,6 +14,7 @@ describe("parseReleaseVersion", () => {
it("parses stable CalVer releases", () => {
expect(parseReleaseVersion("2026.3.10")).toMatchObject({
version: "2026.3.10",
baseVersion: "2026.3.10",
channel: "stable",
year: 2026,
month: 3,
@@ -24,6 +25,7 @@ describe("parseReleaseVersion", () => {
it("parses beta CalVer releases", () => {
expect(parseReleaseVersion("2026.3.10-beta.2")).toMatchObject({
version: "2026.3.10-beta.2",
baseVersion: "2026.3.10",
channel: "beta",
year: 2026,
month: 3,
@@ -32,20 +34,33 @@ describe("parseReleaseVersion", () => {
});
});
it("parses stable correction releases", () => {
expect(parseReleaseVersion("2026.3.10-1")).toMatchObject({
version: "2026.3.10-1",
baseVersion: "2026.3.10",
channel: "stable",
year: 2026,
month: 3,
day: 10,
correctionNumber: 1,
});
});
it("rejects legacy and malformed release formats", () => {
expect(parseReleaseVersion("2026.3.10-1")).toBeNull();
expect(parseReleaseVersion("2026.03.09")).toBeNull();
expect(parseReleaseVersion("v2026.3.10")).toBeNull();
expect(parseReleaseVersion("2026.2.30")).toBeNull();
expect(parseReleaseVersion("2026.3.10-0")).toBeNull();
expect(parseReleaseVersion("2.0.0-beta2")).toBeNull();
});
});
describe("parseReleaseTagVersion", () => {
it("accepts fallback correction tags for stable releases", () => {
it("accepts correction release tags", () => {
expect(parseReleaseTagVersion("2026.3.10-2")).toMatchObject({
version: "2026.3.10-2",
packageVersion: "2026.3.10",
packageVersion: "2026.3.10-2",
baseVersion: "2026.3.10",
channel: "stable",
correctionNumber: 2,
});
@@ -180,6 +195,16 @@ describe("collectReleaseTagErrors", () => {
).toEqual([]);
});
it("accepts correction package versions paired with matching correction tags", () => {
expect(
collectReleaseTagErrors({
packageVersion: "2026.3.10-1",
releaseTag: "v2026.3.10-1",
now: new Date("2026-03-10T00:00:00Z"),
}),
).toEqual([]);
});
it("rejects beta package versions paired with fallback correction tags", () => {
expect(
collectReleaseTagErrors({
+1 -1
View File
@@ -110,7 +110,7 @@ describe("collectPublishablePluginPackageErrors", () => {
).toEqual([
'package name must start with "@openclaw/"; found "broken".',
"package.json private must not be true.",
'package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "latest".',
'package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "latest".',
"openclaw.extensions must contain only non-empty strings.",
]);
});
+17
View File
@@ -137,8 +137,13 @@ describe("collectMissingPackPaths", () => {
expect.arrayContaining([
"dist/channel-catalog.json",
"dist/control-ui/index.html",
"dist/extensions/matrix/helper-api.js",
"dist/extensions/matrix/runtime-api.js",
"dist/extensions/matrix/thread-bindings-runtime.js",
"dist/extensions/matrix/openclaw.plugin.json",
"dist/extensions/matrix/package.json",
"dist/extensions/whatsapp/light-runtime-api.js",
"dist/extensions/whatsapp/runtime-api.js",
"dist/extensions/whatsapp/openclaw.plugin.json",
"dist/extensions/whatsapp/package.json",
]),
@@ -159,6 +164,18 @@ describe("collectMissingPackPaths", () => {
]),
).toEqual([]);
});
it("requires bundled plugin runtime sidecars that dynamic plugin boundaries resolve at runtime", () => {
expect(requiredBundledPluginPackPaths).toEqual(
expect.arrayContaining([
"dist/extensions/matrix/helper-api.js",
"dist/extensions/matrix/runtime-api.js",
"dist/extensions/matrix/thread-bindings-runtime.js",
"dist/extensions/whatsapp/light-runtime-api.js",
"dist/extensions/whatsapp/runtime-api.js",
]),
);
});
});
describe("collectPackUnpackedSizeErrors", () => {
+129 -22
View File
@@ -25,7 +25,7 @@
--text-strong: #f4f4f5;
--chat-text: #d4d4d8;
--muted: #838387;
--muted-strong: #62626a;
--muted-strong: #75757d;
--muted-foreground: #838387;
/* Border - Whisper-thin, barely there */
@@ -189,32 +189,139 @@
/* Theme families override accent tokens while keeping shared surfaces/layout. */
:root[data-theme="openknot"] {
--ring: #4f8ff7;
--accent: #4f8ff7;
--accent-hover: #6da3f9;
--accent-muted: #4f8ff7;
--accent-subtle: rgba(79, 143, 247, 0.12);
--accent-glow: rgba(79, 143, 247, 0.22);
--primary: #4f8ff7;
--primary-foreground: #0e1015;
/*
* Accent crimson red on true black
*
* WCAG 2.1 AA audit (bg = #080808, relative luminance 0.003):
* --accent #e5243b L 0.118 contrast 5.8:1 AA text
* --accent-hover #f03e52 L 0.163 contrast 7.2:1
* --primary-foreground #fafafa on #e5243b button: 4.6:1 AA
* focus-ring at 70% opacity: 3.4:1 against bg (non-text 3:1 required)
*/
--ring: #e5243b;
--accent: #e5243b;
--accent-hover: #f03e52;
--accent-muted: #e5243b;
--accent-subtle: rgba(229, 36, 59, 0.12);
--accent-glow: rgba(229, 36, 59, 0.24);
--primary: #e5243b;
--primary-foreground: #fafafa;
--accent-2: #38bdf8;
--accent-2-muted: rgba(56, 189, 248, 0.7);
--accent-2-subtle: rgba(56, 189, 248, 0.1);
/* Focus — crimson ring */
--focus: rgba(229, 36, 59, 0.2);
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 70%, transparent);
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow);
/* Surfaces — true black canvas */
--bg: #080808;
--bg-accent: #0d0d0f;
--bg-elevated: #141416;
--bg-hover: #1a1a1e;
--bg-muted: #1a1a1e;
--card: #111113;
--card-foreground: #f0f0f2;
--card-highlight: rgba(255, 255, 255, 0.03);
--popover: #141416;
--popover-foreground: #f0f0f2;
--panel: #080808;
--panel-strong: #141416;
--panel-hover: #1a1a1e;
--chrome: rgba(8, 8, 8, 0.96);
--chrome-strong: rgba(8, 8, 8, 0.98);
/* Text — high contrast on black */
--text: #e0e0e2;
--text-strong: #f5f5f7;
--chat-text: #e0e0e2;
--muted: #7a7a80;
--muted-strong: #5a5a62;
--muted-foreground: #7a7a80;
/* Borders — whisper-thin, barely visible */
--border: #1a1a1e;
--border-strong: #2a2a30;
--border-hover: #3a3a42;
--input: #1a1a1e;
--secondary: #111113;
--secondary-foreground: #f0f0f2;
--accent-2: #8b1a2b;
--accent-2-muted: rgba(139, 26, 43, 0.7);
--accent-2-subtle: rgba(139, 26, 43, 0.12);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.6);
--grid-line: rgba(255, 255, 255, 0.025);
}
:root[data-theme="openknot-light"] {
--ring: #2563eb;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-muted: #2563eb;
--accent-subtle: rgba(37, 99, 235, 0.1);
--accent-glow: rgba(37, 99, 235, 0.14);
--primary: #2563eb;
/*
* Accent deep red on cool white
*
* WCAG 2.1 AA audit (bg = #f9f9fb, relative luminance 0.941):
* --accent #c41e30 L 0.078 contrast 7.5:1 AAA text
* --accent-hover #a8192a L 0.054 contrast 9.1:1
* --primary-foreground #ffffff on #c41e30 button: 5.0:1 AA
* focus-ring at 60% opacity: 3.2:1 against bg (non-text 3:1 required)
*/
--ring: #c41e30;
--accent: #c41e30;
--accent-hover: #a8192a;
--accent-muted: #c41e30;
--accent-subtle: rgba(196, 30, 48, 0.08);
--accent-glow: rgba(196, 30, 48, 0.12);
--primary: #c41e30;
--primary-foreground: #ffffff;
--accent-2: #0284c7;
--accent-2-muted: rgba(2, 132, 199, 0.75);
--accent-2-subtle: rgba(2, 132, 199, 0.08);
/* Surfaces — cool white with subtle warmth */
--bg: #f9f9fb;
--bg-accent: #f2f2f5;
--bg-elevated: #ffffff;
--bg-hover: #eaeaef;
--bg-muted: #eaeaef;
--bg-content: #f2f2f5;
--card: #ffffff;
--card-foreground: #18181b;
--card-highlight: rgba(0, 0, 0, 0.02);
--popover: #ffffff;
--popover-foreground: #18181b;
--panel: #f9f9fb;
--panel-strong: #f2f2f5;
--panel-hover: #e4e4ea;
--chrome: rgba(249, 249, 251, 0.96);
--chrome-strong: rgba(249, 249, 251, 0.98);
/* Text — crisp dark on white */
--text: #3a3a42;
--text-strong: #18181b;
--chat-text: #3a3a42;
--muted: #6e6e78;
--muted-strong: #52525a;
--muted-foreground: #6e6e78;
/* Borders — clean, minimal */
--border: #e2e2e8;
--border-strong: #ccccd4;
--border-hover: #adadb8;
--input: #e2e2e8;
--secondary: #f2f2f5;
--secondary-foreground: #3a3a42;
--accent-2: #7a1420;
--accent-2-muted: rgba(122, 20, 32, 0.75);
--accent-2-subtle: rgba(122, 20, 32, 0.08);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.08);
--grid-line: rgba(0, 0, 0, 0.04);
}
:root[data-theme="dash"] {
+4 -30
View File
@@ -133,7 +133,7 @@
.chat-divider__label {
padding: 2px 10px;
border: 1px solid var(--border);
border-radius: 999px;
border-radius: var(--radius-full);
background: rgba(255, 255, 255, 0.02);
}
@@ -141,7 +141,7 @@
.chat-avatar {
width: 36px;
height: 36px;
border-radius: 10px;
border-radius: var(--radius-md);
background: var(--panel-strong);
display: grid;
place-items: center;
@@ -228,15 +228,8 @@ img.chat-avatar {
.chat-copy-btn,
.chat-expand-btn {
border: 1px solid var(--border);
background: var(--bg);
color: var(--muted);
border-radius: var(--radius-md);
padding: 4px 6px;
font-size: 14px;
line-height: 1;
cursor: pointer;
transition: background 120ms ease-out;
}
.chat-copy-btn__icon {
@@ -246,14 +239,10 @@ img.chat-avatar {
position: relative;
}
.chat-copy-btn__icon svg {
.chat-copy-btn__icon svg,
.chat-expand-btn__icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-copy-btn__icon-copy,
@@ -276,11 +265,6 @@ img.chat-avatar {
opacity: 1;
}
.chat-copy-btn:hover,
.chat-expand-btn:hover {
background: var(--bg-hover);
}
.chat-copy-btn[data-copying="1"] {
opacity: 0;
pointer-events: none;
@@ -310,16 +294,6 @@ img.chat-avatar {
height: 14px;
}
.chat-expand-btn__icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Light mode: restore borders */
:root[data-theme-mode="light"] .chat-bubble {
border-color: var(--border);
+49 -81
View File
@@ -59,7 +59,7 @@
margin: 0 0 0 0;
min-height: 0;
/* Allow shrinking for flex scroll behavior */
border-radius: 12px;
border-radius: var(--radius-md);
background: transparent;
}
@@ -121,7 +121,7 @@
color: var(--text);
background: var(--panel-strong);
border: 1px solid var(--border);
border-radius: 999px;
border-radius: var(--radius-full);
cursor: pointer;
white-space: nowrap;
z-index: 10;
@@ -154,7 +154,7 @@
gap: 8px;
padding: 7px 14px;
margin: 0 auto 8px;
border-radius: 999px;
border-radius: var(--radius-full);
border: 1px solid color-mix(in srgb, var(--ctx-color, #d97706) 35%, transparent);
background: var(--ctx-bg, rgba(217, 119, 6, 0.12));
color: var(--ctx-color, #d97706);
@@ -199,7 +199,7 @@
gap: 8px;
padding: 8px;
background: var(--panel);
border-radius: 8px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
width: fit-content;
max-width: 100%;
@@ -211,7 +211,7 @@
position: relative;
width: 80px;
height: 80px;
border-radius: 6px;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--border);
background: var(--bg);
@@ -285,7 +285,7 @@
.chat-message-image {
max-width: 300px;
max-height: 200px;
border-radius: 8px;
border-radius: var(--radius-md);
object-fit: contain;
cursor: pointer;
transition: transform 150ms ease-out;
@@ -331,7 +331,7 @@
min-height: 40px;
max-height: 150px;
padding: 9px 12px;
border-radius: 8px;
border-radius: var(--radius-md);
overflow-y: auto;
resize: none;
white-space: pre-wrap;
@@ -373,7 +373,6 @@
border: 1px solid var(--border);
border-radius: var(--radius-lg);
flex-shrink: 0;
overflow: hidden;
transition:
border-color var(--duration-fast) ease,
box-shadow var(--duration-fast) ease;
@@ -431,7 +430,7 @@
}
.agent-chat__input-btn,
.agent-chat__toolbar .btn-ghost {
.agent-chat__toolbar .btn--ghost {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -448,24 +447,24 @@
}
.agent-chat__input-btn svg,
.agent-chat__toolbar .btn-ghost svg {
.agent-chat__toolbar .btn--ghost svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke: currentColor;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.agent-chat__input-btn:hover:not(:disabled),
.agent-chat__toolbar .btn-ghost:hover:not(:disabled) {
.agent-chat__toolbar .btn--ghost:hover:not(:disabled) {
color: var(--text);
background: var(--bg-hover);
}
.agent-chat__input-btn:disabled,
.agent-chat__toolbar .btn-ghost:disabled {
.agent-chat__toolbar .btn--ghost:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@@ -645,6 +644,38 @@
color: var(--text);
}
.slash-menu-badge {
font-size: 0.65rem;
font-weight: 600;
padding: 1px 6px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
white-space: nowrap;
flex-shrink: 0;
}
.slash-menu-footer {
display: flex;
gap: 10px;
padding: 6px 10px 4px;
font-size: 0.68rem;
color: var(--muted);
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
margin-top: 4px;
}
.slash-menu-footer kbd {
display: inline-block;
padding: 1px 4px;
font-size: 0.65rem;
font-family: var(--mono);
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
line-height: 1.3;
}
.chat-attachments-preview {
display: flex;
gap: 8px;
@@ -734,18 +765,6 @@
font-size: 13px;
}
/* Icon button style */
.btn--icon {
padding: 8px !important;
min-width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.06);
}
/* Controls separator */
.chat-controls__separator {
color: rgba(255, 255, 255, 0.4);
@@ -758,57 +777,6 @@
color: rgba(16, 24, 40, 0.3);
}
.btn--icon:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
}
/* Light theme icon button overrides */
:root[data-theme-mode="light"] .btn--icon {
background: #ffffff;
border-color: var(--border);
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
color: var(--muted);
}
:root[data-theme-mode="light"] .btn--icon:hover {
background: #ffffff;
border-color: var(--border-strong);
color: var(--text);
}
/* Light theme icon button overrides */
:root[data-theme-mode="light"] .btn--icon {
background: #ffffff;
border-color: var(--border);
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
color: var(--muted);
}
:root[data-theme-mode="light"] .btn--icon:hover {
background: #ffffff;
border-color: var(--border-strong);
color: var(--text);
}
:root[data-theme-mode="light"] .chat-controls .btn--icon.active {
border-color: var(--accent);
background: var(--accent-subtle);
color: var(--accent);
box-shadow: 0 0 0 1px var(--accent-subtle);
}
.btn--icon svg {
display: block;
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-controls__session select {
padding: 6px 10px;
font-size: 13px;
@@ -828,7 +796,7 @@
font-size: 12px;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.04);
border-radius: 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
@@ -929,7 +897,7 @@
.agent-chat__avatar--logo {
width: 48px;
height: 48px;
border-radius: 14px;
border-radius: var(--radius-lg);
background: var(--panel-strong);
border: 1px solid var(--border);
display: grid;
@@ -959,7 +927,7 @@
color: var(--muted);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 100px;
border-radius: var(--radius-full);
padding: 4px 12px;
}
@@ -982,7 +950,7 @@
font-family: var(--font-mono);
background: var(--panel-strong);
border: 1px solid var(--border);
border-radius: 4px;
border-radius: var(--radius-sm);
}
.agent-chat__suggestions {
@@ -997,7 +965,7 @@
.agent-chat__suggestion {
font-size: 13px;
padding: 8px 16px;
border-radius: 100px;
border-radius: var(--radius-full);
border: 1px solid var(--border);
background: var(--panel);
color: var(--foreground);
+1 -1
View File
@@ -96,7 +96,7 @@
.sidebar-markdown pre {
background: rgba(0, 0, 0, 0.12);
border-radius: 4px;
border-radius: var(--radius-sm);
padding: 12px;
overflow-x: auto;
}
+4 -4
View File
@@ -5,7 +5,7 @@
.chat-thinking {
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 10px;
border-radius: var(--radius-md);
border: 1px dashed rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.04);
color: var(--muted);
@@ -72,14 +72,14 @@
.chat-text :where(:not(pre) > code) {
background: rgba(0, 0, 0, 0.15);
padding: 0.15em 0.4em;
border-radius: 4px;
border-radius: var(--radius-sm);
overflow-wrap: normal;
word-break: keep-all;
}
.chat-text :where(pre) {
background: rgba(0, 0, 0, 0.15);
border-radius: 6px;
border-radius: var(--radius-sm);
padding: 10px 12px;
overflow-x: auto;
}
@@ -162,7 +162,7 @@
.chat-text :where(pre) {
padding: 8px 10px;
font-size: 12px;
border-radius: 4px;
border-radius: var(--radius-sm);
}
.chat-text :where(.markdown-inline-image) {
+75 -5
View File
@@ -588,7 +588,7 @@
font-size: 11px;
font-weight: 500;
line-height: 1;
border-radius: 4px;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.15);
color: inherit;
opacity: 0.8;
@@ -627,6 +627,48 @@
font-size: 12px;
}
.btn--xs {
padding: 4px 6px;
font-size: 12px;
line-height: 1;
}
.btn--icon {
padding: 8px;
min-width: 36px;
height: 36px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.06);
}
.btn--icon:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
}
.btn--icon svg {
display: block;
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.btn--ghost {
border-color: transparent;
background: transparent;
color: var(--muted);
}
.btn--ghost:hover {
background: var(--bg-hover);
color: var(--text);
border-color: transparent;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -1176,6 +1218,34 @@
border-color: var(--accent);
}
:root[data-theme-mode="light"] .btn--icon {
background: #ffffff;
border-color: var(--border);
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
color: var(--muted);
}
:root[data-theme-mode="light"] .btn--icon:hover {
background: #ffffff;
border-color: var(--border-strong);
color: var(--text);
}
:root[data-theme-mode="light"] .chat-controls .btn--icon.active {
border-color: var(--accent);
background: var(--accent-subtle);
color: var(--accent);
box-shadow: 0 0 0 1px var(--accent-subtle);
}
:root[data-theme-mode="light"] .btn--ghost {
background: transparent;
}
:root[data-theme-mode="light"] .btn--ghost:hover {
background: var(--bg-hover);
}
/* ===========================================
Utilities
=========================================== */
@@ -1230,7 +1300,7 @@
line-height: 1.2;
padding: 6px 14px;
margin-bottom: 8px;
border-radius: 999px;
border-radius: var(--radius-full);
border: 1px solid var(--border);
background: var(--panel-strong);
color: var(--text);
@@ -1316,14 +1386,14 @@
.code-block-wrapper {
position: relative;
border-radius: 6px;
border-radius: var(--radius-sm);
overflow: hidden;
margin-top: 0.75em;
}
.code-block-wrapper pre {
margin: 0;
border-radius: 0 0 6px 6px;
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
.code-block-header {
@@ -2319,7 +2389,7 @@ td.data-table-key-col {
.chat-new-messages {
align-self: center;
margin: 8px auto 0;
border-radius: 999px;
border-radius: var(--radius-full);
padding: 6px 12px;
font-size: 12px;
line-height: 1;

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