Compare commits

..

100 Commits

Author SHA1 Message Date
Vincent Koc
5aaa9a2e5c fix(security): remediate CodeQL alerts 2026-04-30 00:08:32 -07:00
Vincent Koc
a093b5b2de fix(skills): bound grouped skill directory scans 2026-04-30 00:03:19 -07:00
Vincent Koc
02597caa8b chore(ci): add agent CodeQL PR quality guard
Promotes the existing agent-runtime quality shard to PR/manual selection and documents the expanded twelve-shard PR quality set.
2026-04-30 00:01:12 -07:00
Otto Deng
8ca1f6d590 fix(skills): scan grouped skill directories
* fix(skills): scan nested subdirectories for grouped skill layouts

Previously, skill discovery only checked immediate children of the
skills root for SKILL.md files. Skills organized in subdirectories
(e.g. ~/.openclaw/skills/coze/koze-retrieval/SKILL.md) were silently
ignored.

Now, when an immediate child directory does not contain a SKILL.md,
its own children are checked one level deeper. This supports grouped
skill layouts while keeping the scan depth bounded (max 2 levels) to
avoid unbounded filesystem traversal.

The existing per-source skill count limits and containment checks
still apply to nested discoveries.

Fixes #56915

* test(skills): cover nested grouped skill discovery

* fix(skills): cache contained-path checks and cap nested scans

- Reuse skillDirRealPath captured during the collection phase so the load
  loop no longer re-runs resolveContainedSkillPath on the same directory.
- Apply the per-root candidate cap (and the matching warning log) when
  descending into nested grouped skill directories, matching the outer
  scan's behavior.

Addresses Greptile P2 feedback on PR #72534.

* fix(skills): load grouped skill directories under skills roots

* fix(clownfish): address review for ghcrawl-156697-autonomous-smoke (1)

---------

Co-authored-by: Otto Deng <otto@ottodeng.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: Otto Deng <ottodeng2@github.local>
2026-04-29 23:56:19 -07:00
Vincent Koc
d18fdecd53 test(channels): align module loader jiti fixture 2026-04-29 23:46:39 -07:00
NianJiu
43ca7399e5 Fix CLI text command hangs (#74220)
* fix(cli): keep agents list off plugin preload

* docs(changelog): note cli text hang fix

* test(cli): update preaction agents list expectations
2026-04-30 06:36:24 +00:00
Galin Iliev
c4a4c189f1 fix: enable native require fast path on Windows for bundled plugins (#74173)
Removes the win32 exclusion from supportsNativeJitiRuntime() and adds { allowWindows: true } to all tryNativeRequireJavaScriptModule call sites, so bundled plugin modules use native require() instead of Jiti on Windows. Also adds an attempted-load counter to the debug timing log and a changelog entry.

Fixes #68656

Co-authored-by: Galin Iliev <galiniliev@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 23:32:20 -07:00
Vincent Koc
e0c75cd0bd chore(ci): cover bundled channels in CodeQL PR guard
Extends the channel CodeQL quality shard to bundled channel plugin source directories and documents the scoped PR guard coverage.
2026-04-29 23:28:18 -07:00
clawsweeper[bot]
d55fafd130 fix(ci): disable install smoke Docker build cache
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 23:16:10 -07:00
Vincent Koc
423f6df5b1 chore(ci): add config CodeQL PR quality guard
Adds the config-boundary quality shard to the PR CodeQL guard and documents the expanded eleven-shard PR quality set.
2026-04-29 23:15:58 -07:00
clawsweeper[bot]
6dbaa0a278 fix(plugins): keep disabled plugin runtime deps off
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 23:15:47 -07:00
clawsweeper[bot]
fbc145440f fix(slack): offset presentation controls after native blocks
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 23:15:19 -07:00
Vincent Koc
a265abaf29 docs(changelog): backfill c34ed90822 control UI refresh-during-runs guard 2026-04-29 23:07:28 -07:00
Vincent Koc
3031726905 chore(ci): add auth CodeQL PR quality guard
Adds the core-auth-secrets quality shard to the PR CodeQL guard and documents the expanded ten-shard PR quality set.
2026-04-29 23:06:02 -07:00
Val Alexander
c34ed90822 fix(control-ui): disable refresh during active runs
Disable the Control UI refresh button while chat is disconnected, loading, sending, running, or streaming.

This prevents manual chat-history refresh from racing active run/stream state and adds browser render coverage for the disabled-state matrix.

Closes #65522.

Validation:
- Exact PR head `1511a086614a727fc4200730e7ad9622134bb7d3` reached `CLEAN` merge state.
- GitHub CI for the exact head completed with no failed or pending checks.
2026-04-30 01:02:14 -05:00
Vincent Koc
e9d4cb2bb6 chore(ci): add memory CodeQL PR quality guard
Adds the memory runtime quality shard to the PR CodeQL guard while preserving provider/plugin overlap only for the memory files that share those contracts.
2026-04-29 22:54:37 -07:00
Vincent Koc
c259a90b3b fix(ui): refresh Persian locale copy 2026-04-29 22:51:36 -07:00
Vincent Koc
c500b26bb6 chore(ci): add plugin SDK reply CodeQL PR guard
Adds the Plugin SDK reply runtime quality shard to the PR CodeQL guard while keeping reply runtime changes on the existing plugin and package-contract shards.
2026-04-29 22:43:24 -07:00
clawsweeper[bot]
897ca6abbb fix: Windows-specific reliability gap in the new timeout cleanup path (#74703)
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 22:43:09 -07:00
github-actions[bot]
0c74952bcf chore(ui): refresh fa control ui locale 2026-04-30 05:39:39 +00:00
clawsweeper[bot]
9177fab07b fix: environment edge case launcher regression (#74696)
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 22:39:12 -07:00
clawsweeper[bot]
3c9437ae54 fix: configs that used the previously documented WhatsApp exposeErrorText key now fail valida... (#74667)
* fix: configs that used the previously documented WhatsApp exposeErrorText key now fail valida...

* fix(clawsweeper): address review for clawsweeper-commit-openclaw-openclaw-4cba08df01ea (1)

---------

Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 22:34:59 -07:00
clawsweeper[bot]
1ff1fbe682 fix(plugins): honor runtime deps fallback install option
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 22:28:15 -07:00
clawsweeper[bot]
44296fcd2b fix(sdk): emit replacement chat projection deltas
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 22:28:05 -07:00
clawsweeper[bot]
b876ecdb84 fix(plugins): select runtime deps by configured models
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 22:27:54 -07:00
clawsweeper[bot]
0459206c40 fix(gateway): preserve rpc abort terminal snapshots
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 22:27:44 -07:00
Vincent Koc
a34ba362c6 chore(ci): add session CodeQL PR quality guard
Adds the session diagnostics quality shard to the PR CodeQL guard while keeping diagnostics and delivery queue analysis path-sharded by surface.
2026-04-29 22:27:27 -07:00
clawsweeper[bot]
1a9763f578 fix(google): accept Windows ADC manifest paths
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 22:26:09 -07:00
clawsweeper[bot]
9189b16c1c fix(bedrock): expose Opus 4.7 max thinking
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 22:25:58 -07:00
clawsweeper[bot]
59e7053464 fix(plugins): prefer require export conditions
* fix: fixed condition order prefers a top-level require export before a node condition, which...

* fix(clawsweeper): address review for clawsweeper-commit-openclaw-openclaw-6877360218c9 (1)

---------

Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 22:25:48 -07:00
clawsweeper[bot]
ebf05be742 fix(slack): preserve mixed interactive blocks
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 22:25:38 -07:00
clawsweeper[bot]
c6c518e6e9 fix(slack): cap select option values
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 22:25:29 -07:00
Vincent Koc
4fc0981a52 chore(ci): add process CodeQL PR quality guard
Adds the MCP/process runtime quality shard to the PR CodeQL guard and keeps non-security quality analysis path-sharded by surface.
2026-04-29 22:15:17 -07:00
openclaw-clownfish[bot]
3af4575a84 fix(media): treat legacy Word docs as binary attachments
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-04-29 22:07:21 -07:00
clawsweeper[bot]
fa1b8a25b8 test(ci): guard install smoke docker cache removal
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 22:06:02 -07:00
clawsweeper[bot]
ccb43f95cb fix(channels): suppress observe-only prepared dispatch
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 22:05:28 -07:00
clawsweeper[bot]
87a211d309 fix(slack): cap approval update fallback text
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 22:05:18 -07:00
clawsweeper[bot]
19d6404168 fix(slack): share edit fallback text truncation
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 22:05:07 -07:00
Vincent Koc
1c0b02a297 docs(ci): rewrite for structure, deduplication, and findability
Splits the previous wall-of-prose docs/ci.md into discoverable sections
while preserving every operator-relevant detail:

- Lead orientation paragraph kept; cross-links to umbrella and prerelease
- Pipeline overview anchors the job table at the top
- Fail-fast order tightened; superseded-run/concurrency notes folded in
- Scope and routing surfaces ci-changed-scope.mjs, the routing-only fast
  path, the Windows scope rule, Vitest shard balancing, the Android
  dual-flavor rule, and the check-dependencies (Knip + unused-file
  allowlist) pass that was buried in the lead
- Manual dispatches groups examples + include_android + target_ref
- Runners and Local equivalents tables/blocks preserved
- Full Release Validation: release_profile and rerun_group bulleted;
  verifier-only rerun guidance and the shared release-package-under-test
  artifact called out
- Live and E2E shards: native-live shard names listed, live-media-runner
  image and openclaw-live-test:<sha> with OPENCLAW_SKIP_DOCKER_BUILD=1
  broken out
- Package Acceptance split into Jobs / Candidate sources / Suite profiles
  / Legacy compatibility windows / Examples / debugging
- Install smoke: fast vs full paths, main-push policy, Bun gate
- Local Docker E2E: scheduler tunables in a table, reusable workflow
  flow, release-path chunks list, rerun helpers
- Plugin Prerelease, QA Lab, CodeQL each get their own discoverable
  sections; CodeQL uses tables for security and quality categories
  instead of paragraph walls (kept the new provider-runtime-boundary
  shard in the PR-quality-guard list)
- Maintenance workflows groups Docs Agent, Test Performance Agent, and
  Duplicate PRs After Merge
- Local check gates and changed routing turn boundary lane rules into
  bullets and keep the explicit-mapping prose
- Testbox validation kept; Related links preserved

Audited every workflow name and CodeQL category against
.github/workflows/ — no stale references. File goes from 527 to 413
lines while preserving shard names, env vars, profiles, chunks, and
legacy-compat windows. Layout obeys oxfmt.
2026-04-29 22:04:44 -07:00
Vincent Koc
6308d2a1dc chore(ci): add channel CodeQL PR quality guard
Adds the channel runtime quality shard to the PR CodeQL guard and keeps non-security quality analysis path-sharded by surface.
2026-04-29 22:00:55 -07:00
dependabot[bot]
2d53b1d314 build(deps): bump debian docker base digest
Bumps the docker-images group with 1 update in the / directory: debian.


Updates `debian` from `4724b8c` to `f9c6a2f`

---
updated-dependencies:
- dependency-name: debian
  dependency-version: bookworm-slim
  dependency-type: direct:production
  dependency-group: docker-images
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-04-29 21:55:26 -07:00
clawsweeper[bot]
6689e414bb fix(gateway): avoid caching empty model catalogs
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 21:53:25 -07:00
clawsweeper[bot]
a6af23a1de fix(test): keep kitchen-sink conformance diagnostics clean
* fix: test-harness regression risk

* fix: keep kitchen-sink conformance diagnostics clean

---------

Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper-repair <clawsweeper-repair@users.noreply.github.com>
2026-04-29 21:53:15 -07:00
clawsweeper[bot]
54bebc5f5e fix(commands): require gateway memory probe skipped state
Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
2026-04-29 21:52:07 -07:00
dependabot[bot]
4d8c155d33 chore(deps): bump swift-testing
Bumps [github.com/apple/swift-testing](https://github.com/apple/swift-testing) from 0.99.0 to 6.3.1.
- [Release notes](https://github.com/apple/swift-testing/releases)
- [Commits](https://github.com/apple/swift-testing/compare/0.99.0...6.3.1)

---
updated-dependencies:
- dependency-name: github.com/apple/swift-testing
  dependency-version: 6.3.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 21:48:39 -07:00
dependabot[bot]
9cb71bbaab chore(deps): bump actions group
Bumps the actions group with 2 updates in the / directory: [useblacksmith/setup-docker-builder](https://github.com/useblacksmith/setup-docker-builder) and [useblacksmith/build-push-action](https://github.com/useblacksmith/build-push-action).


Updates `useblacksmith/setup-docker-builder` from 1.7.0 to 1.8.0
- [Release notes](https://github.com/useblacksmith/setup-docker-builder/releases)
- [Commits](ac083cc846...722e97d12b)

Updates `useblacksmith/build-push-action` from 2.1.0 to 2.2.0
- [Release notes](https://github.com/useblacksmith/build-push-action/releases)
- [Commits](cbd1f60d19...fb9e3e6a92)

---
updated-dependencies:
- dependency-name: useblacksmith/build-push-action
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: useblacksmith/setup-docker-builder
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 21:48:30 -07:00
Vincent Koc
8dc99feb50 chore(ci): add provider CodeQL PR quality guard
Adds the provider runtime quality shard to the PR CodeQL guard, keeps PR quality analysis path-sharded by surface, and fixes selector overlap for Plugin SDK/package-contract paths.
2026-04-29 21:47:17 -07:00
拐爷&&老拐瘦
3224075edc fix: reject invalid cron edits on disabled jobs (#74720)
* fix(cron): reject invalid disabled schedule updates

* docs: add cron validation changelog entry

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-04-30 04:33:43 +00:00
Vincent Koc
eea964330c chore(ci): add gateway CodeQL PR quality guard
Adds the gateway runtime quality shard to the PR CodeQL guard, keeps PR quality analysis path-sharded by surface, and documents the shard selector behavior.
2026-04-29 21:26:03 -07:00
hcl
2de6ad4544 fix(exec): preserve turnSourceChannel as messageProvider in approval followup runs (#74666)
When an exec-approval followup run has no deliverable route and no
gateway-internal channel, buildAgentFollowupArgs was passing channel=undefined
to the spawned agent. This left defaults.messageProvider=undefined in the
followup run, causing tools.elevated.allowFrom.<provider> checks to always
fail with provider=null after the user approved an async elevated command.

Thread turnSourceChannel through buildAgentFollowupArgs and use it as a
fallback when sessionOnlyOriginChannel is absent. Fixes #74646.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 04:25:16 +00:00
hcl
38aac70830 fix(feishu): skip empty-text messages with no media to prevent blank session turns (#74634) (#74661)
Feishu delivers empty-text events (e.g. {"text":""}) when users send
blank messages or when a media-only message produces no text content.
Writing a blank user turn to the session file causes downstream LLM
providers such as MiniMax to reject requests with:

  invalid params, messages must not be empty (2013)

Guard at the point after media resolution: if ctx.content.trim() is
empty AND mediaList is empty, log the skip and return without queuing
a reply. This preserves all existing behaviour for text, media, and
mixed messages.

Regression test: dispatch a DM with {"text":""} (no media), assert
mockDispatchReplyFromConfig is not called.

Closes #74634. Thanks @xdengli.
2026-04-30 04:24:27 +00:00
hcl
5716428adc fix(acp): fall through to thread-bound resolution when token is unresolvable (#66299) (#74641)
* fix(acp): fall through to thread-bound resolution when token is unresolvable (#66299)

resolveAcpTargetSessionKey returned an error immediately when an explicit
session token was supplied but could not be resolved as a key/id/label.
This blocked the thread-bound and requester-session fallback paths from
ever being reached.

Discord slash commands auto-fill the current thread ID as a positional
ACP target. That value is not a session identifier, so the gateway lookup
returns null, and the command returned 'Unable to resolve session target'
instead of falling through to the thread-bound session that was already
known via the binding context.

Fix: when the token lookup returns null, skip the early-exit error and
fall through to thread-bound → requester-session → error in the normal
way. The 'Missing session key' error still surfaces when neither fallback
produces a binding.

Adds a focused regression test: unresolvable token + bound thread session
→ steer command reaches the thread-bound session, not an error.

Fixes #66299

* fix(changelog): add Thanks @martingarramon attribution for #66299

Per clawsweeper P2 review — every new CHANGELOG entry must credit
at least one author. martingarramon authored the issue analysis and
explicitly invited the PR.

* fix(acp): preserve bad-token diagnostics after thread fallback

---------

Co-authored-by: clawsweeper-repair <clawsweeper-repair@users.noreply.github.com>
2026-04-30 04:24:21 +00:00
Peter Steinberger
e648f38efc fix: stabilize Parallels update restart checks 2026-04-30 05:22:04 +01:00
Peter Steinberger
d363565375 fix: harden Windows Parallels update smoke 2026-04-30 05:22:04 +01:00
Peter Steinberger
d5e4ec9ea8 fix: accept extensionless runtime dependency mains 2026-04-30 05:22:04 +01:00
Peter Steinberger
c976cf6ebd chore: refresh a2ui bundle hash 2026-04-30 05:22:04 +01:00
Shubhankar Tripathy
0142c79123 config: accept browser.tabCleanup keys in zod schema (#74577) (#74638)
* config: accept browser.tabCleanup keys in zod schema (#74577)

* docs: update config baseline hash

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-04-30 04:21:09 +00:00
Peter Steinberger
4b3f91c686 fix(active-memory): clarify fallback config help (#74602) (thanks @jeffrey701) 2026-04-30 05:17:27 +01:00
jeffrey701
c894dbf0ae fix(active-memory): clarify modelFallbackPolicy deprecation warning text
Closes #74587. AI-assisted, fully tested.

The previous deprecation warning ("set config.modelFallback explicitly
if you want a fallback model") read naturally as runtime failover —
model A errors → switch to model B. The actual semantics in
`getModelRef` are different: `modelFallback` is the **last candidate
in the chain-resolution walk**, consulted only when `config.model`,
the current run's model, AND the agent's configured default have all
resolved to nothing. There is no error-recovery / retry-with-different-model
path.

The mismatch wastes real debug time. The issue filer reports ~1 hour of
cycles before reading source revealed the gap; users without source
access can debug for much longer assuming runtime failover exists.

## Fix

Rewrite the warning string to:

1. State the deprecation (preserved).
2. Describe `modelFallback`'s actual semantics — chain-resolution
   last-resort, gated on the three earlier candidates resolving to
   nothing.
3. Explicitly disclaim the wrong mental model — "it is NOT a runtime
   failover that substitutes a different model when the resolved model
   errors out" — so a quick read can't lead the operator astray.

No behavior change, only operator-facing copy. Surrounding code paths
(`getModelRef`, `hasDeprecatedModelFallbackPolicy`, the warn caller in
`register()`) are untouched.

## Tests

`extensions/active-memory/index.test.ts` extends the existing
deprecation-warning assertion to pin both the positive copy
(`chain-resolution`, `last-resort`) and the negative disclaimer
(`NOT a runtime failover`), so a future "let's reword this" change
that reintroduces the failover-implying language fails the test
instead of silently regressing.

`pnpm test extensions/active-memory/index.test.ts` — 94 passed.
`pnpm exec oxfmt --check` — clean. `pnpm exec oxlint` — 0 warnings,
0 errors.

## AI-assisted PR

- [x] Mark as AI-assisted (Claude). Lightly tested via the targeted
  Vitest extension shard; not exercised against a live Ollama / AM
  rollout because the change is a log-string update, not behavior.
- [x] Confirm I understand what the code does: yes — `getModelRef`
  walks four candidates (`config.model`, `currentRunModel`,
  `configuredDefaultModel`, `config.modelFallback`) and returns the
  first non-null parse; `modelFallback` is purely a default-when-empty
  selector, not a runtime failover.
2026-04-30 05:17:27 +01:00
Peter Steinberger
395ad91323 fix: cap slack approval update text 2026-04-30 05:16:25 +01:00
Peter Steinberger
c4f9cf1a27 fix: cap slack edit fallback text 2026-04-30 05:12:04 +01:00
Peter Steinberger
30774786f1 fix: cap slack block fallback text 2026-04-30 05:12:03 +01:00
Peter Steinberger
c316dbfc4a fix: keep slack message controls 2026-04-30 05:12:03 +01:00
Peter Steinberger
035b70aed1 docs: credit doctor memory probe fix (#74653) (thanks @hclsys) 2026-04-30 05:10:32 +01:00
HCL
96482b3e62 test(doctor): add skipped: false to gateway error and timeout test assertions 2026-04-30 05:10:32 +01:00
HCL
549624ffb2 fix(doctor): add skipped discriminator to distinguish probe skip from gateway timeout
Previously both a planned probe skip (probe:false path) and a transport timeout
returned checked:false, so the renderer's !checked early return would silently
suppress diagnostics for key-optional providers even when the gateway had timed out.

- Add `skipped?: boolean` to GatewayMemoryProbe: true for gateway-confirmed skip,
  false for timeout/unavailable paths
- Renderer now guards on `probe.skipped` instead of `!probe.checked`, so timeouts
  fall through to the existing warning path
- Update doctor-memory-search inline type and buildGatewayProbeWarning signature
- Update skipped-probe tests to pass { skipped: true }; add regression test for
  key-optional timeout (lmstudio gateway timeout now warns)

Addresses clawsweeper P2: src/commands/doctor-memory-search.ts:416
2026-04-30 05:10:32 +01:00
HCL
34d62b0650 fix(doctor): propagate gateway skipped-probe flag through adapter
clawsweeper P1: probeGatewayMemoryStatus always returned checked: true
on successful RPC, silently discarding payload.embedding.checked === false
from the SKIPPED_MEMORY_EMBEDDING_PROBE gateway response. The renderer
guard in noteMemorySearchHealth (added in prior commit) never saw checked:
false in real execution — only on timeout paths.

Fix: propagate checked flag from payload.embedding.checked so a skipped
gateway probe surfaces as checked: false to the renderer, allowing the
key-optional provider guard to suppress the false-positive warning.

Add adapter-level regression test that verifies the skipped payload shape
from doctor.memory.status reaches GatewayMemoryProbe as checked: false.
2026-04-30 05:10:32 +01:00
HCL
45082aaed3 fix(doctor): suppress false-positive embedding warning when probe skipped
When `openclaw doctor` runs without --deep, the gateway probe is skipped
and returns { checked: false, ready: false } (SKIPPED_MEMORY_EMBEDDING_PROBE).
Key-optional providers (ollama, lmstudio, local) were incorrectly shown
"could not confirm embeddings are ready" in this case, misleading users
into thinking their fully-functional embedding setup had an issue.

Guard the key-optional provider path: if probe.checked is false (probe
was skipped, not run), return early without warning. A skipped probe
carries no readiness signal — it is not a failure.

- Adds two focused regression tests for ollama and lmstudio with
  skipped probe (checked: false) → expect note() not called
- Updates the prior test that expected a warning on checked:false
  to reflect the corrected behaviour

Fixes #74608
2026-04-30 05:10:32 +01:00
Peter Steinberger
d7396d4ffa fix(channels): keep status accessors config-only 2026-04-30 05:08:32 +01:00
Vincent Koc
2a6809467a docs(changelog): backfill 1f1f70a23f gateway sessions abort wait semantics 2026-04-29 21:07:16 -07:00
Peter Steinberger
5c46ccba0b docs: update 2026.4.29 changelog 2026-04-30 05:05:14 +01:00
Peter Steinberger
56155e5048 test: accept kitchen sink conformance diagnostics 2026-04-30 05:04:49 +01:00
clawsweeper[bot]
0603c2327d fix(file-transfer): require canonical node policy authorization (#74742)
* feat(file-transfer): add bundled plugin for binary file ops on nodes

New extensions/file-transfer/ plugin exposing four agent tools
(file_fetch, dir_list, dir_fetch, file_write) and four matching
node-host commands (file.fetch, dir.list, dir.fetch, file.write).
Lets agents read and write files on paired nodes by absolute path,
bypassing the bash output cap (200KB) and the live tool-result
text cap that would otherwise truncate base64 payloads.

Public surface
--------------
- file_fetch({ node, path, maxBytes? })
  Image MIMEs return image content blocks; small text (<=8 KB) inlines
  as text content; everything else returns a saved-media-path text
  block. sha256-verified end-to-end.
- dir_list({ node, path, pageToken?, maxEntries? })
  Structured directory listing — name, path, size, mimeType, isDir,
  mtime. Paginated. No content transfer.
- dir_fetch({ node, path, maxBytes?, includeDotfiles? })
  Server-side tar -czf streamed back, unpacked into the gateway media
  store, returns a manifest of saved paths. Single round-trip.
  60s wall-clock timeouts on tar create/unpack. tar -xzf without -P
  rejects absolute paths in archive entries.
- file_write({ node, path, contentBase64, mimeType?, overwrite?,
              createParents? })
  Atomic write (temp + rename). Refuses to overwrite by default.
  Refuses to write through symlinks (lstat check). Buffer-side
  sha256 (no read-back race). Pair with file_fetch to round-trip
  files between nodes — DO NOT use exec/cp for file copies.

All four commands gated by:
  - dangerous-by-default node command policy
    (gateway.nodes.allowCommands opt-in)
  - per-node path policy (gateway.nodes.fileTransfer)
  - optional operator approval prompt (ask: off | on-miss | always)

16 MB raw byte ceiling per single-frame round-trip (25 MB WS frame
with ~33% base64 overhead and JSON envelope). 8 MB defaults.

Path policy and approvals
-------------------------
Default behavior is DENY. The operator must explicitly opt in:

  {
    "gateway": {
      "nodes": {
        "fileTransfer": {
          "<nodeId-or-displayName>": {
            "ask":              "off" | "on-miss" | "always",
            "allowReadPaths":   ["~/Screenshots/**", "/tmp/**"],
            "allowWritePaths":  ["~/Downloads/**"],
            "denyPaths":        ["**/.ssh/**", "**/.aws/**"],
            "maxBytes":         16777216
          },
          "*": { "ask": "on-miss" }
        }
      }
    }
  }

ask modes:
  off       — silent: allow if matched, deny if not (default)
  on-miss   — silent allow if matched; prompt on miss
  always    — prompt every call (denyPaths still hard-deny)

denyPaths always wins. allow-always from the prompt persists the
exact path back into allowReadPaths/allowWritePaths via
mutateConfigFile so subsequent matching calls go silent.

Reuses existing primitives — no new gateway methods:
  plugin.approval.request / plugin.approval.waitDecision
  decision: allow-once | allow-always | deny

Pre-flight against requested path AND post-flight against the
canonicalPath returned by the node — closes symlink-escape attacks
where the requested path matched policy but realpath resolves
somewhere else.

Audit log
---------
JSONL at ~/.openclaw/audit/file-transfer.jsonl. Records every
decision (allow/allowed-once/allowed-always/denied/error) with
timestamp, op, nodeId, displayName, requestedPath, canonicalPath,
decision, error code, sizeBytes, sha256, durationMs. Best-effort
writes; never propagates failure.

Plugin layout
-------------
extensions/file-transfer/
  index.ts                       definePluginEntry, nodeHostCommands
  openclaw.plugin.json           contracts.tools registration
  package.json
  src/node-host/{file-fetch,dir-list,dir-fetch,file-write}.ts
  src/tools/{file-fetch,dir-list,dir-fetch,file-write}-tool.ts
  src/shared/
    mime.ts        single-source extension->MIME map + image/text sets
    errors.ts      shared error code enum and helpers
    params.ts      shared param-validation helpers + GatewayCallOptions
    policy.ts      evaluateFilePolicy, persistAllowAlways
    approval.ts    plugin.approval.request wrapper
    gatekeep.ts    one-stop policy + approval + audit orchestrator
    audit.ts       JSONL audit sink

Core touch points
-----------------
- src/infra/node-commands.ts: NODE_FILE_FETCH_COMMAND,
  NODE_DIR_LIST_COMMAND, NODE_DIR_FETCH_COMMAND,
  NODE_FILE_WRITE_COMMAND, NODE_FILE_COMMANDS array
- src/gateway/node-command-policy.ts: all four added to
  DEFAULT_DANGEROUS_NODE_COMMANDS
- src/security/audit-extra.sync.ts: audit detail mentions file ops
- src/agents/tools/nodes-tool-media.ts: MEDIA_INVOKE_ACTIONS entry
  for file.fetch redirects raw nodes(action=invoke) callers to the
  dedicated file_fetch tool to prevent base64 context bloat
- src/agents/tools/nodes-tool.ts: nodes tool description points to
  the dedicated file_fetch tool

Known limitations / follow-ups
------------------------------
- No tests in this PR. For a security-sensitive surface this is a
  gap; will follow up with a test pass.
- Direct CLI invocation (openclaw nodes invoke --command file.fetch)
  bypasses the plugin policy entirely. Plugin-side gating is the
  realistic threat model (agent on iMessage requesting paths it
  shouldn't), but for true defense-in-depth, policy belongs in the
  gateway-side node.invoke dispatch. Move-policy-to-core is a
  separate PR.
- file_watch (long-lived filesystem event subscription) is not
  included; it needs a new node-protocol primitive for streaming
  event channels and was descoped from this PR.
- dir_fetch includeDotfiles: true is the only supported mode;
  BSD tar exclude patterns reliably collapse dotfile filtering
  to an empty archive. Reliable filtering needs a
  `find ! -name ".*" | tar -T -` pipeline; deferred.
- dir_fetch du -sk preflight is a heuristic (du * 4 vs maxBytes);
  the mid-stream byte cap is the actual safety net.

* test(file-transfer): add unit tests for handlers, policy, and shared utilities

Adds 77 tests covering:
- handleFileFetch: validation, fs errors, sha256, size cap, symlink canonicalization
- handleFileWrite: validation, atomic write, overwrite policy, parent dir handling, symlink refusal, integrity check, size cap
- handleDirList: validation, fs errors, sorted listing, dotfile inclusion, pagination
- handleDirFetch: validation, fs errors, gzipped tar with sha256, mid-stream byte cap
- evaluateFilePolicy: default-deny, denyPaths-wins, allow matching, ask modes (off/on-miss/always), node-id/displayName/'*' resolution
- persistAllowAlways: append, dedupe, create-on-missing
- shared/mime: extension lookup, image/text inline sets
- shared/errors: err helper, classifyFsError, throwFromNodePayload

Also fixes accumulated lint regressions in the prod source flagged once these
files moved into the changed-gate scope (parseInt -> Number.parseInt, redundant
type casts removed, single-statement if bodies wrapped in braces).

* fix(file-transfer): address PR review feedback (security + availability)

Reviewer findings addressed (greptile + aisle):

- policy: persistAllowAlways no longer escalates per-node approvals to the
  '*' wildcard entry; allow-always now writes under the specific node's
  own entry, never the wildcard (greptile P1 SECURITY).
- policy: add literal '..' segment short-circuit in evaluateFilePolicy,
  raised before glob match. Stops "/allowed/../etc/passwd" from passing
  preflight against "/allowed/**" globs (aisle MEDIUM CWE-22).
- file-write: replace no-op base64 try/catch with actual round-trip
  validation. Buffer.from(s, "base64") never throws — invalid input
  silently decoded to garbage bytes. Now re-encodes and compares
  modulo padding/url-variant chars (greptile P1 SECURITY).
- file-write: document the parent-symlink residual risk and rely on the
  existing gateway-side post-flight policy check; full rollback requires
  a node-side file.unlink which is deferred to a follow-up. Initial
  segment-walk attempt was reverted because it false-positives on system
  symlinks like macOS /var → /private/var (aisle HIGH CWE-59).
- dir-fetch tool: add preValidateTarball pass that runs `tar -tzvf` and
  rejects symlinks, hardlinks, absolute paths, '..' traversal,
  uncompressed sizes >64MB, and entry counts >5000 — before any
  extraction. Drops --no-overwrite-dir (GNU-only flag rejected by BSD
  tar on macOS) (aisle HIGH x2 CWE-22 + CWE-409, greptile P2).
- dir-fetch tool: stream-hash files via fs.open + read loop instead of
  fs.readFile to avoid full-buffer reads on large extracted entries.
- dir-fetch handler: replace spawnSync in countTarEntries with async
  spawn + bounded buffer so tar -tzf can't park the node-host event
  loop for up to 10s on a slow filesystem (greptile P1 AVAIL).
- audit: clear auditDirPromise on rejection so a transient mkdir
  failure doesn't permanently silence the audit log (greptile P2).

New tests: wildcard escalation rejection, base64 malformed/url-variant,
'..' traversal short-circuit (3 cases). 84/84 passing.

* fix(file-transfer): CI failures + second-round PR review feedback

CI failures on previous push:

- Declare runtime deps (minimatch, typebox) in package.json — failed the
  extension-runtime-dependencies contract test that scans imports.
- Switch policy.ts and policy.test.ts off the broad
  openclaw/plugin-sdk/config-runtime barrel and onto the narrow
  openclaw/plugin-sdk/config-mutation + runtime-config-snapshot subpaths.
  This satisfies the deprecated-internal-config-api architecture guard.

Second-round Aisle findings:

- policy: traversal-segment check now treats backslash and forward slash
  as equivalent, so a Windows node can't be hit with mixed-separator
  "C:\\allowed\\..\\Windows\\system.ini" (Aisle HIGH CWE-22).
- dir-fetch tool: replace the single fragile `tar -tvzf` parser pass
  (which broke for filenames containing whitespace) with two robust
  passes: `tar -tzf` for paths only (one per line, no parsing of
  fixed columns) and `tar -tzvf` for type chars only (FIRST CHAR of each
  line, never the path column). Also reject backslash-containing entry
  names. Drops the in-process uncompressed-size cap because reliably
  parsing sizes from tar output is fragile and Aisle flagged it as a
  bypass primitive — entry-count cap stays (Aisle HIGH CWE-22, MED).

Tests still 84/84 passing.

* fix(file-transfer): third-round PR review feedback

Aisle's re-analysis on b63daa6a05 surfaced 3 actionable findings:

- nodes.invoke bypass (HIGH CWE-285): generic nodes.action="invoke" let
  agents call dir.list/dir.fetch/file.write directly, skipping the
  file-transfer plugin's gatekeep + policy + approval flow. Only file.fetch
  was redirected to its dedicated tool. Add the other three to
  MEDIA_INVOKE_ACTIONS so the redirect-or-deny logic in
  nodes-tool-commands fires for all four. The dedicated tools enforce
  policy; the generic invoke surface no longer has a way to skip them
  without an explicit allowMediaInvokeCommands opt-in.
- prototype pollution in persistAllowAlways (MED CWE-1321): a paired
  node with displayName "__proto__" / "prototype" / "constructor" would
  mutate the fileTransfer object's prototype when persisting allow-always.
  Reject those keys explicitly. Switch the existing-key lookup to
  Object.prototype.hasOwnProperty.call so a key like "constructor"
  doesn't accidentally match Object.prototype.constructor.
- decompression-bomb cap in dir_fetch (MED CWE-409): compressed tar is
  bounded upstream, but a highly compressible bomb can still expand to
  gigabytes. Enforce DIR_FETCH_MAX_UNCOMPRESSED_BYTES (64MB) summed
  across extracted files and DIR_FETCH_MAX_SINGLE_FILE_BYTES (16MB) per
  entry, both checked during the post-extract walk. On bust, rm -rf the
  rootDir and audit-log + throw UNCOMPRESSED_TOO_LARGE.

Tests: 85/85 passing (added prototype-pollution rejection test).

Aisle's HIGH parent-symlink finding remains documented as deferred — full
rollback requires a node-side file.unlink command which is out of scope
for this PR. The gateway-side post-flight policy check still detects and
loudly errors on canonical-path mismatches.

* fix(file-transfer): refuse symlink traversal by default with followSymlinks opt-in

Closes the deferred Aisle HIGH parent-symlink finding. Instead of
detecting the escape in a post-flight gateway check after the file is
already written, the node-side handler now refuses pre-flight if any
component of the requested path resolves through a symlink.

Behavior:
- Reads (file.fetch / dir.list / dir.fetch): node realpath()s the
  requested path. If canonical != requested AND followSymlinks=false,
  return SYMLINK_REDIRECT { canonicalPath } — no I/O happens.
- Writes (file.write): node realpath()s the parent dir. Same refusal
  rule. The lstat-on-final check is kept to catch the case where the
  target file itself is an existing symlink.
- Opt-in: set gateway.nodes.fileTransfer.<node>.followSymlinks=true to
  bring back the previous "follow + post-flight check" behavior.

Operator UX: the SYMLINK_REDIRECT response includes the canonical path
so the operator can either update their allow list to the canonical form
or set followSymlinks=true on that node. On macOS, /var → /private/var
and /tmp → /private/tmp are system aliases that trip the new check, so
operators using those paths need followSymlinks=true OR canonical-path
allowlists.

Wiring:
- Add followSymlinks?: boolean to NodeFilePolicyConfig.
- evaluateFilePolicy returns followSymlinks (default false) on its
  ok=true branches.
- gatekeep propagates it via GatekeepOutcome.
- Each tool passes it as a node.invoke param.
- Each handler honors it pre-flight before any read/write.

Tests updated: 89/89 passing.
- realpath(mkdtemp()) so existing happy-path tests don't trip the new
  default on macOS where mkdtemp lands under symlinked /var/folders.
- New tests: SYMLINK_REDIRECT refusal for file.fetch and file.write
  parent traversal; opt-in passthrough when followSymlinks=true.
- New policy test: followSymlinks propagation default false / true.

* fix(file-transfer): close two more aisle findings on 069bd66

Aisle re-analysis on 069bd66 surfaced two issues my earlier round-three
fix missed:

- HIGH (CWE-284): file.fetch / dir.fetch / dir.list / file.write were
  still bypassable via the generic nodes.action="invoke" surface when
  the operator had set allowMediaInvokeCommands=true. That flag was
  meant to opt in to base64-bloat for camera/screen, not to disable
  path policy on file-transfer. Split the redirect map: introduce
  POLICY_REDIRECT_INVOKE_COMMANDS (file-transfer only) which ALWAYS
  rerouts to its dedicated tool regardless of the bloat flag. Camera
  and screen continue to use the bloat-only redirect (suppressed by
  allowMediaInvokeCommands=true). Confirmed by clawsweeper P1.
- MED (CWE-276): tar -xzf in dir_fetch unpack preserved archive
  ownership and permissions, so a malicious node could plant
  setuid/setgid or world-writable files on a gateway running with
  elevated privileges. Add --no-same-owner --no-same-permissions
  (both flags are portable across BSD tar / GNU tar).

Tests: 89/89 passing.

* chore(file-transfer): drop file_watch from plugin description

Phase 5 (file_watch) was deferred earlier in this PR. Strip the watch
mention from the plugin description in package.json,
openclaw.plugin.json, and index.ts so the metadata reflects what's
actually shipped (file_fetch, dir_list, dir_fetch, file_write).
Closes clawsweeper P3.

* fix(file-transfer): hash before rename and allow zero-byte round-trip

Two of Peter's review findings on PR #74134:

- P2 (file-write integrity): hash the decoded buffer + compare against
  expectedSha256 BEFORE temp+rename. Previously the rename happened
  first, then the sha check unlinked the target on mismatch — with
  overwrite=true a bad caller hash could replace + delete the original.
  Now a hash mismatch returns INTEGRITY_FAILURE without touching disk.
  Added a regression test that asserts the original file survives.

- P2/P3 (zero-byte round-trip): the tool layer's truthy checks on
  contentBase64 and base64 rejected the empty string, blocking zero-byte
  files from round-tripping through file_fetch -> file_write. Switched
  to type-checks (typeof === "string") and added zero-byte tests at the
  handler layer for both fetch and write (sha matches the known empty
  digest).

Tests: 92/92 passing.

* fix(file-transfer): declare gateway.nodes.fileTransfer in core config schema

Peter's P1/P2 finding: the plugin reads/writes gateway.nodes.fileTransfer
via casts through unknown because the strict zod schema and OpenClawConfig
type didn't declare it. That meant `openclaw config validate` would
reject the very examples in the plugin's own documentation.

- Add fileTransfer block to gateway.nodes in src/config/zod-schema.ts
  with the full per-node entry shape (ask, allowReadPaths,
  allowWritePaths, denyPaths, maxBytes, followSymlinks).
- Add GatewayNodeFileTransferEntry + the fileTransfer field on
  GatewayNodesConfig in src/config/types.gateway.ts.
- Drop the `as unknown` casts in the extension's policy.ts now that
  gateway.nodes.fileTransfer is properly typed end-to-end.
- Regenerate docs/.generated/config-baseline.sha256.

Tests: 92/92 passing. pnpm config:docs:check OK.

* fix(file-transfer): enforce path policy at gateway dispatch

Closes Peter's P1 review finding on PR #74134.

The agent-tool-only redirect added in earlier commits left CLI
(`openclaw nodes invoke`), plugin-runtime, and raw `node.invoke` callers
able to skip the file-transfer path policy entirely. The fix moves the
security boundary down to the gateway: every code path that reaches
`node.invoke` for file.fetch / dir.list / dir.fetch / file.write now
runs the same allow/deny check.

- New: src/gateway/file-transfer-dispatch.ts with
  `evaluateFileTransferDispatchPolicy` and `isFileTransferCommand`. Same
  semantics as the extension-side `evaluateFilePolicy` minus the
  operator-prompt flow (prompts stay at the agent-tool layer; the
  gateway is silent enforcement).
- src/gateway/server-methods/nodes.ts: after the existing command
  allowlist check, run the new gate before forwarding. Denies emit
  INVALID_REQUEST with a structured `{ command, code, reason }`.
- Decision matrix mirrors the extension: NO_POLICY (no entry for
  this node) deny, denyPaths-wins, '..' traversal short-circuit
  (with backslash separator handling), allowPaths match → allow,
  no allow match → deny.
- 19 new unit tests covering each branch including identity
  resolution (nodeId/displayName/'*'), prototype-pollution-safe lookup,
  and read-vs-write allow-list separation.

Note on allow-once approvals: the agent tool's interactive
`allow-once` decision now has to flow through the dedicated tool's
pre-flight (which forwards an approved request); raw `nodes.invoke`
callers cannot benefit from one-time approvals because the gateway is
silent. allow-always (which persists to allowReadPaths/allowWritePaths)
continues to work transparently because by the time the next request
hits the gateway the path is in the persisted allow list.

Tests: 92 extension + 19 gateway = 111 total, all passing.

* fix(file-transfer): enforce node policy in gateway

* fix(file-transfer): use plugin node policy only

* fix(file-transfer): harden node policy edge cases

* fix(file-transfer): close review hardening gaps

* fix(file-transfer): harden node invoke policy

* fix(file-transfer): align runtime dependency versions

* fix(file-transfer): keep minimatch extension-owned

* refactor(file-transfer): remove unused approval gate

* fix(file-transfer): require canonical node policy authorization

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>

* fix(clawsweeper): address review for automerge-openclaw-openclaw-74134 (1)

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>

* fix(file-transfer): recheck dir fetch archive policy after fetch

* fix(file-transfer): name file-transfer tool in invoke redirect

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: clawsweeper-repair <clawsweeper-repair@users.noreply.github.com>
2026-04-30 04:03:40 +00:00
Peter Steinberger
d80a8eb3ad fix(agents): drop metadata-only replay turns
Fixes #74745
2026-04-30 04:58:05 +01:00
Peter Steinberger
bb44909262 docs: update changelog for Discord SecretRef accessor (#74737) 2026-04-30 04:57:07 +01:00
천유신
e4ca4c7fbf fix(discord): avoid resolving tokens for read-only accessors 2026-04-30 04:57:07 +01:00
Peter Steinberger
94cb213544 fix: stabilize full release validation 2026-04-30 04:55:23 +01:00
Val Alexander
1f1f70a23f fix(gateway): align sessions abort wait semantics (#74751) thanks @BunsDev
Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-04-29 22:55:19 -05:00
bitloi
e6abd9e3d8 fix(sdk): stabilize run event chat projections (#74750) thanks @bitloi
Co-authored-by: bitloi <raphaelaloi.eth@gmail.com>
2026-04-29 22:54:52 -05:00
Peter Steinberger
5f13af6b68 fix: warn before npm prefix redirection (#73890) (thanks @Sayeem3051) 2026-04-30 04:49:03 +01:00
Peter Steinberger
bbf932fd7d fix(channels): preserve observe-only turn compatibility 2026-04-30 04:20:40 +01:00
Peter Steinberger
7a2bb2fcda docs: document high-confidence triage candidate filter 2026-04-30 04:18:16 +01:00
Peter Steinberger
a89fe0f5a0 docs: update plugin runtime changelog 2026-04-30 04:13:52 +01:00
Peter Steinberger
6877360218 fix(plugins): prefer require runtime aliases 2026-04-30 04:13:39 +01:00
Peter Steinberger
5138d3f8b6 fix(plugins): resolve plugin paths from root 2026-04-30 04:13:39 +01:00
Peter Steinberger
09310931cf fix(plugins): repair configured runtime deps 2026-04-30 04:13:39 +01:00
Peter Steinberger
db18323551 fix(plugin-sdk): restore zalouser facade 2026-04-30 04:13:39 +01:00
Peter Steinberger
9e5d6c7091 docs: credit macos attach-only launchd fix 2026-04-30 04:10:54 +01:00
Luka Dolenc
07605c79ad style(macos): order attach-only test modifiers 2026-04-30 04:10:54 +01:00
Luka Dolenc
25d2e9bdac fix(macos): keep attach-only from stopping gateway launchd 2026-04-30 04:10:54 +01:00
Peter Steinberger
ffe67e9cdc refactor(channels): route inbound turns through kernel 2026-04-30 04:08:47 +01:00
Vincent Koc
6e73101df3 chore(ci): widen CodeQL PR guard
Runs the PR CodeQL security guard as high-confidence high/critical security coverage and adds the initial plugin/package-contract quality guard.
2026-04-29 20:06:50 -07:00
Peter Steinberger
8672737f81 fix: drop overlong slack command values 2026-04-30 04:04:45 +01:00
Peter Steinberger
d25cfda54c fix: cap slack command menu blocks 2026-04-30 04:04:44 +01:00
Peter Steinberger
a4af1e91da docs(changelog): thank memory forget fix contributor 2026-04-30 04:03:41 +01:00
Peter Steinberger
757894e201 test(memory-lancedb): mock embedding transport in forget test 2026-04-30 04:03:41 +01:00
amittell
6f7c89ce21 fix(lint): resolve oxlint errors 2026-04-30 04:03:41 +01:00
amittell
faad655c21 fix(memory-lancedb): show full IDs in memory_forget candidate list 2026-04-30 04:03:41 +01:00
openclaw-clownfish[bot]
873df76132 fix(feishu): clean up bitable placeholder rows with empty defaults
Preserve the Feishu-local cleanup path while matching the Lark SDK record value shapes: recursively delete default-empty strings, nulls, arrays, and nested text spans, but keep meaningful links, attachments, users, locations, numbers, and booleans.\n\nCarries forward #40602. Thanks @boat2moon.
2026-04-30 04:01:49 +01:00
openclaw-clownfish[bot]
0e97f962ac fix(mattermost): add WebSocket ping/pong keepalive (#73979)
Adds Mattermost WebSocket ping/pong liveness checks so half-open sockets terminate and the existing reconnect loop recovers.

Fixes #41837.
Carries forward #57621.
Refs #50138, #44160, and #51104.
Thanks @JasonWang1124.

Co-authored-by: JasonWang1124 <56307673+JasonWang1124@users.noreply.github.com>
2026-04-30 03:57:31 +01:00
257 changed files with 14489 additions and 2654 deletions

View File

@@ -41,6 +41,28 @@ gitcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --
- `invalid`
- `dirty` for PRs only
## Select small high-confidence triage candidates
When asked for `X` issues or PRs to triage, `X` means qualified candidates, not sampled threads.
Only list candidates that pass all gates:
- small owner/surface, with a likely narrow fix and focused regression test
- symptom is reproducible or provable with logs, failing test, live command, dependency contract, or current-main behavior
- root cause is traceable to code with file/line and the proposed fix touches that path
- no strong smell that a broader refactor, ownership rethink, migration, or product decision is the better fix
- dependency-backed behavior checked against upstream docs/source/types; live or web proof used when local proof is insufficient
Loop:
1. Use `gitcrawl` / `gh` to gather candidate clusters.
2. Read issue/PR body, comments, current code, adjacent tests, and dependency contracts.
3. Try focused repro or proof.
4. Reject unclear, stale, speculative, broad-refactor, or owner-ambiguous items.
5. Continue until `X` qualified candidates or the bounded search is exhausted.
Output only qualifying candidates, with: ref, surface, proof, cause, fix sketch, why small, expected test/gate. If none qualify, say so; do not pad.
## Enforce the bug-fix evidence bar
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.

View File

@@ -1,5 +1,18 @@
name: openclaw-codeql-actions-critical-security
disable-default-queries: true
queries:
- uses: security-extended
query-filters:
- include:
precision:
- high
- very-high
tags contain: security
security-severity: /([7-9]|10)\.(\d)+/
paths:
- .github/actions
- .github/workflows

View File

@@ -14,6 +14,29 @@ query-filters:
- security
paths:
- extensions/bluebubbles/src
- extensions/discord/src
- extensions/feishu/src
- extensions/googlechat/src
- extensions/imessage/src
- extensions/irc/src
- extensions/line/src
- extensions/matrix/src
- extensions/mattermost/src
- extensions/msteams/src
- extensions/nextcloud-talk/src
- extensions/nostr/src
- extensions/qa-channel/src
- extensions/qqbot/src
- extensions/signal/src
- extensions/slack/src
- extensions/synology-chat/src
- extensions/telegram/src
- extensions/tlon/src
- extensions/twitch/src
- extensions/whatsapp/src
- extensions/zalo/src
- extensions/zalouser/src
- src/channels
paths-ignore:

View File

@@ -10,10 +10,8 @@ query-filters:
precision:
- high
- very-high
- exclude:
problem.severity:
- recommendation
- warning
tags contain: security
security-severity: /([7-9]|10)\.(\d)+/
paths:
- src/channels

View File

@@ -10,10 +10,8 @@ query-filters:
precision:
- high
- very-high
- exclude:
problem.severity:
- recommendation
- warning
tags contain: security
security-severity: /([7-9]|10)\.(\d)+/
paths:
- src/agents/*auth*.ts

View File

@@ -14,8 +14,11 @@ query-filters:
- security
paths:
- src/gateway/method-scopes.ts
- src/gateway/protocol
- src/gateway/server-methods
- src/gateway/server-methods.ts
- src/gateway/server-methods-list.ts
paths-ignore:
- "**/node_modules"

View File

@@ -10,10 +10,8 @@ query-filters:
precision:
- high
- very-high
- exclude:
problem.severity:
- recommendation
- warning
tags contain: security
security-severity: /([7-9]|10)\.(\d)+/
paths:
- src/mcp

View File

@@ -10,10 +10,8 @@ query-filters:
precision:
- high
- very-high
- exclude:
problem.severity:
- recommendation
- warning
tags contain: security
security-severity: /([7-9]|10)\.(\d)+/
paths:
- src/infra/net

View File

@@ -10,10 +10,8 @@ query-filters:
precision:
- high
- very-high
- exclude:
problem.severity:
- recommendation
- warning
tags contain: security
security-severity: /([7-9]|10)\.(\d)+/
paths:
- src/cli/plugin-install-config-policy.ts

6
.github/labeler.yml vendored
View File

@@ -9,6 +9,12 @@
- "extensions/azure-speech/**"
- "docs/providers/azure-speech.md"
- "docs/tools/tts.md"
"plugin: file-transfer":
- changed-files:
- any-glob-to-any-file:
- "extensions/file-transfer/**"
- "docs/nodes/index.md"
- "docs/plugins/sdk-runtime.md"
"channel: discord":
- changed-files:
- any-glob-to-any-file:

View File

@@ -10,16 +10,127 @@ on:
type: choice
options:
- all
- agent-runtime-boundary
- config-boundary
- core-auth-secrets
- channel-runtime-boundary
- gateway-runtime-boundary
- memory-runtime-boundary
- mcp-process-runtime-boundary
- plugin-boundary
- plugin-sdk-package-contract
- plugin-sdk-reply-runtime
- provider-runtime-boundary
- session-diagnostics-boundary
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- ".github/codeql/**"
- ".github/workflows/codeql-critical-quality.yml"
- "packages/plugin-package-contract/**"
- "packages/plugin-sdk/**"
- "packages/memory-host-sdk/**"
- "src/config/**"
- "extensions/bluebubbles/src/**"
- "extensions/discord/src/**"
- "extensions/feishu/src/**"
- "extensions/googlechat/src/**"
- "extensions/imessage/src/**"
- "extensions/irc/src/**"
- "extensions/line/src/**"
- "extensions/matrix/src/**"
- "extensions/mattermost/src/**"
- "extensions/msteams/src/**"
- "extensions/nextcloud-talk/src/**"
- "extensions/nostr/src/**"
- "extensions/qa-channel/src/**"
- "extensions/qqbot/src/**"
- "extensions/signal/src/**"
- "extensions/slack/src/**"
- "extensions/synology-chat/src/**"
- "extensions/telegram/src/**"
- "extensions/tlon/src/**"
- "extensions/twitch/src/**"
- "extensions/whatsapp/src/**"
- "extensions/zalo/src/**"
- "extensions/zalouser/src/**"
- "src/agents/*auth*.ts"
- "src/agents/**/*auth*.ts"
- "src/agents/auth-health*.ts"
- "src/agents/auth-profiles"
- "src/agents/auth-profiles/**"
- "src/agents/bash-tools.exec-host-shared.ts"
- "src/agents/sandbox"
- "src/agents/sandbox/**"
- "src/agents/sandbox.ts"
- "src/agents/sandbox-*.ts"
- "src/acp/control-plane/**"
- "src/agents/cli-runner/**"
- "src/agents/command/**"
- "src/agents/pi-embedded-runner/**"
- "src/agents/tools/**"
- "src/agents/*completion*.ts"
- "src/agents/*transport*.ts"
- "src/agents/model-*.ts"
- "src/agents/openclaw-tools*.ts"
- "src/agents/provider-*.ts"
- "src/agents/session*.ts"
- "src/agents/tool-call*.ts"
- "src/auto-reply/reply/agent-runner*.ts"
- "src/auto-reply/reply/commands*.ts"
- "src/auto-reply/reply/directive-handling*.ts"
- "src/auto-reply/reply/dispatch-*.ts"
- "src/auto-reply/reply/get-reply-run*.ts"
- "src/auto-reply/reply/provider-dispatcher*.ts"
- "src/auto-reply/reply/queue*.ts"
- "src/auto-reply/reply/reply-run-registry*.ts"
- "src/auto-reply/reply/session*.ts"
- "src/channels/**"
- "src/auto-reply/reply/post-compaction-context.ts"
- "src/auto-reply/reply/queue/**"
- "src/auto-reply/reply/startup-context.ts"
- "src/commands/doctor-cron-dreaming-payload-migration.ts"
- "src/commands/doctor-memory-search.ts"
- "src/commands/doctor-session-*.ts"
- "src/commands/session-store-targets.ts"
- "src/commands/sessions*.ts"
- "src/cron/service/jobs.ts"
- "src/cron/stagger.ts"
- "src/gateway/*auth*.ts"
- "src/gateway/**/*auth*.ts"
- "src/gateway/*secret*.ts"
- "src/gateway/**/*secret*.ts"
- "src/gateway/protocol/**/*secret*.ts"
- "src/gateway/resolve-configured-secret-input-string*.ts"
- "src/gateway/security-path*.ts"
- "src/gateway/server-methods/secrets*.ts"
- "src/gateway/server-startup-memory.ts"
- "src/gateway/method-scopes.ts"
- "src/gateway/protocol/**"
- "src/gateway/server-methods/**"
- "src/gateway/server-methods.ts"
- "src/gateway/server-methods-list.ts"
- "src/infra/diagnostic-*.ts"
- "src/infra/diagnostics-timeline.ts"
- "src/infra/outbound/**"
- "src/infra/secret-file*.ts"
- "src/infra/session-delivery-queue*.ts"
- "src/logging/diagnostic*.ts"
- "src/memory/**"
- "src/memory-host-sdk/**"
- "src/mcp/**"
- "src/model-catalog/**"
- "src/plugin-sdk/**"
- "src/plugins/**"
- "src/process/**"
- "src/secrets/**"
- "src/security/**"
schedule:
- cron: "30 6 * * *"
concurrency:
group: codeql-critical-quality-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
cancel-in-progress: false
group: codeql-critical-quality-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -27,12 +138,170 @@ env:
permissions:
actions: read
contents: read
pull-requests: read
security-events: write
jobs:
quality-shards:
name: Select Critical Quality shards
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 5
outputs:
agent: ${{ steps.detect.outputs.agent }}
channel: ${{ steps.detect.outputs.channel }}
config: ${{ steps.detect.outputs.config }}
core_auth_secrets: ${{ steps.detect.outputs.core_auth_secrets }}
gateway: ${{ steps.detect.outputs.gateway }}
memory: ${{ steps.detect.outputs.memory }}
mcp_process: ${{ steps.detect.outputs.mcp_process }}
plugin: ${{ steps.detect.outputs.plugin }}
plugin_sdk_package: ${{ steps.detect.outputs.plugin_sdk_package }}
plugin_sdk_reply: ${{ steps.detect.outputs.plugin_sdk_reply }}
provider: ${{ steps.detect.outputs.provider }}
session_diagnostics: ${{ steps.detect.outputs.session_diagnostics }}
steps:
- name: Detect PR shard paths
id: detect
env:
EVENT_NAME: ${{ github.event_name }}
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
agent=false
channel=false
config=false
core_auth_secrets=false
gateway=false
memory=false
mcp_process=false
plugin=false
plugin_sdk_package=false
plugin_sdk_reply=false
provider=false
session_diagnostics=false
if [[ "${EVENT_NAME}" != "pull_request" ]]; then
agent=true
channel=true
config=true
core_auth_secrets=true
gateway=true
memory=true
mcp_process=true
plugin=true
plugin_sdk_package=true
plugin_sdk_reply=true
provider=true
session_diagnostics=true
else
while IFS= read -r file; do
case "${file}" in
.github/codeql/*|.github/workflows/codeql-critical-quality.yml)
agent=true
channel=true
config=true
core_auth_secrets=true
gateway=true
memory=true
mcp_process=true
plugin=true
plugin_sdk_package=true
plugin_sdk_reply=true
provider=true
session_diagnostics=true
;;
src/acp/control-plane/*|src/agents/cli-runner/*|src/agents/command/*|src/agents/pi-embedded-runner/*|src/agents/tools/*|src/agents/*completion*.ts|src/agents/*transport*.ts|src/agents/model-*.ts|src/agents/openclaw-tools*.ts|src/agents/provider-*.ts|src/agents/session*.ts|src/agents/tool-call*.ts|src/auto-reply/reply/agent-runner*.ts|src/auto-reply/reply/commands*.ts|src/auto-reply/reply/directive-handling*.ts|src/auto-reply/reply/dispatch-*.ts|src/auto-reply/reply/get-reply-run*.ts|src/auto-reply/reply/provider-dispatcher*.ts|src/auto-reply/reply/queue*.ts|src/auto-reply/reply/reply-run-registry*.ts|src/auto-reply/reply/session*.ts)
agent=true
;;
src/auto-reply/reply/post-compaction-context.ts|src/auto-reply/reply/queue/*|src/auto-reply/reply/startup-context.ts|src/commands/doctor-session-*.ts|src/commands/session-store-targets.ts|src/commands/sessions*.ts|src/infra/diagnostic-*.ts|src/infra/diagnostics-timeline.ts|src/infra/session-delivery-queue*.ts|src/logging/diagnostic*.ts)
session_diagnostics=true
;;
extensions/bluebubbles/src/*|extensions/discord/src/*|extensions/feishu/src/*|extensions/googlechat/src/*|extensions/imessage/src/*|extensions/irc/src/*|extensions/line/src/*|extensions/matrix/src/*|extensions/mattermost/src/*|extensions/msteams/src/*|extensions/nextcloud-talk/src/*|extensions/nostr/src/*|extensions/qa-channel/src/*|extensions/qqbot/src/*|extensions/signal/src/*|extensions/slack/src/*|extensions/synology-chat/src/*|extensions/telegram/src/*|extensions/tlon/src/*|extensions/twitch/src/*|extensions/whatsapp/src/*|extensions/zalo/src/*|extensions/zalouser/src/*|src/channels/*)
channel=true
;;
src/config/*)
config=true
;;
src/gateway/protocol/*secret*.ts|src/gateway/server-methods/secrets*.ts)
core_auth_secrets=true
gateway=true
;;
src/agents/*auth*.ts|src/agents/auth-health*.ts|src/agents/auth-profiles|src/agents/auth-profiles/*|src/agents/bash-tools.exec-host-shared.ts|src/agents/sandbox|src/agents/sandbox.ts|src/agents/sandbox-*.ts|src/agents/sandbox/*|src/cron/service/jobs.ts|src/cron/stagger.ts|src/gateway/*auth*.ts|src/gateway/*secret*.ts|src/gateway/resolve-configured-secret-input-string*.ts|src/gateway/security-path*.ts|src/infra/secret-file*.ts|src/secrets/*|src/security/*)
core_auth_secrets=true
;;
src/gateway/method-scopes.ts|src/gateway/protocol/*|src/gateway/server-methods/*|src/gateway/server-methods.ts|src/gateway/server-methods-list.ts)
gateway=true
;;
packages/memory-host-sdk/*|src/commands/doctor-cron-dreaming-payload-migration.ts|src/commands/doctor-memory-search.ts|src/gateway/server-startup-memory.ts|src/memory/*|src/memory-host-sdk/*)
memory=true
;;
src/infra/outbound/base-session-key.ts|src/infra/outbound/delivery-queue*.ts|src/infra/outbound/outbound-session.ts|src/infra/outbound/session-binding*.ts|src/infra/outbound/session-context.ts|src/infra/outbound/targets-session.ts)
mcp_process=true
session_diagnostics=true
;;
src/infra/outbound/*|src/mcp/*|src/process/*)
mcp_process=true
;;
src/plugin-sdk/inbound-envelope.ts|src/plugin-sdk/inbound-reply-dispatch.ts|src/plugin-sdk/reply-*.ts|src/plugin-sdk/channel-reply-*.ts|src/plugin-sdk/delivery-queue-runtime.ts|src/plugin-sdk/outbound-runtime.ts|src/plugin-sdk/outbound-send-deps.ts|src/plugin-sdk/model-session-runtime.ts|src/plugin-sdk/session-*.ts|src/plugin-sdk/thread-bindings-runtime.ts|src/plugin-sdk/thread-bindings-session-runtime.ts|src/plugin-sdk/conversation-binding-runtime.ts)
plugin=true
plugin_sdk_package=true
plugin_sdk_reply=true
;;
src/plugin-sdk/memory-*.ts|src/plugin-sdk/memory-core-host-*.ts)
memory=true
plugin=true
plugin_sdk_package=true
;;
src/plugin-sdk/*)
plugin=true
plugin_sdk_package=true
;;
src/plugins/provider-contract-public-artifacts.ts|src/plugins/provider-public-artifacts.ts|src/plugins/web-provider-public-artifacts*.ts)
plugin=true
provider=true
;;
src/plugins/memory-embedding-provider*.ts|src/plugins/memory-embedding-providers*.ts)
memory=true
provider=true
;;
src/plugins/memory-*.ts)
memory=true
;;
src/model-catalog/*|src/plugins/*provider*.ts|src/plugins/capability-provider-runtime.ts|src/plugins/compaction-provider.ts|src/plugins/memory-embedding-provider*.ts|src/plugins/memory-embedding-providers*.ts|src/plugins/migration-provider-runtime.ts|src/plugins/synthetic-auth.runtime.ts|src/plugins/web-fetch-providers*.ts|src/plugins/web-search-providers*.ts)
provider=true
;;
src/plugins/activation-planner.ts|src/plugins/api-builder.ts|src/plugins/bundled-*.ts|src/plugins/captured-registration.ts|src/plugins/config-*.ts|src/plugins/discovery.ts|src/plugins/effective-plugin-ids.ts|src/plugins/externalized-bundled-plugins.ts|src/plugins/installed-plugin-index*.ts|src/plugins/loader*.ts|src/plugins/manifest*.ts|src/plugins/module-export.ts|src/plugins/package-entrypoints.ts|src/plugins/plugin-registry*.ts|src/plugins/public-surface*.ts|src/plugins/registry.ts|src/plugins/registry-types.ts|src/plugins/runtime|src/plugins/runtime/*|src/plugins/runtime-state.ts|src/plugins/runtime.ts|src/plugins/sdk-alias.ts|src/plugins/source-loader.ts|src/plugins/types.ts|src/plugins/validation-diagnostics.ts)
plugin=true
;;
packages/plugin-package-contract/*|packages/plugin-sdk/*)
plugin_sdk_package=true
;;
esac
done < <(gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" --jq '.[].filename')
fi
{
echo "agent=${agent}"
echo "channel=${channel}"
echo "config=${config}"
echo "core_auth_secrets=${core_auth_secrets}"
echo "gateway=${gateway}"
echo "memory=${memory}"
echo "mcp_process=${mcp_process}"
echo "plugin=${plugin}"
echo "plugin_sdk_package=${plugin_sdk_package}"
echo "plugin_sdk_reply=${plugin_sdk_reply}"
echo "provider=${provider}"
echo "session_diagnostics=${session_diagnostics}"
} >> "${GITHUB_OUTPUT}"
core-auth-secrets:
name: Critical Quality (core-auth-secrets)
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' }}
needs: quality-shards
if: ${{ needs.quality-shards.outputs.core_auth_secrets == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'core-auth-secrets') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
@@ -54,7 +323,8 @@ jobs:
config-boundary:
name: Critical Quality (config-boundary)
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' }}
needs: quality-shards
if: ${{ needs.quality-shards.outputs.config == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'config-boundary') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
@@ -76,7 +346,8 @@ jobs:
gateway-runtime-boundary:
name: Critical Quality (gateway-runtime-boundary)
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' }}
needs: quality-shards
if: ${{ needs.quality-shards.outputs.gateway == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'gateway-runtime-boundary') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
@@ -98,7 +369,8 @@ jobs:
channel-runtime-boundary:
name: Critical Quality (channel-runtime-boundary)
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' }}
needs: quality-shards
if: ${{ needs.quality-shards.outputs.channel == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'channel-runtime-boundary') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
@@ -120,7 +392,8 @@ jobs:
agent-runtime-boundary:
name: Critical Quality (agent-runtime-boundary)
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' }}
needs: quality-shards
if: ${{ needs.quality-shards.outputs.agent == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'agent-runtime-boundary') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
@@ -142,7 +415,8 @@ jobs:
mcp-process-runtime-boundary:
name: Critical Quality (mcp-process-runtime-boundary)
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' }}
needs: quality-shards
if: ${{ needs.quality-shards.outputs.mcp_process == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'mcp-process-runtime-boundary') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
@@ -164,7 +438,8 @@ jobs:
memory-runtime-boundary:
name: Critical Quality (memory-runtime-boundary)
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' }}
needs: quality-shards
if: ${{ needs.quality-shards.outputs.memory == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'memory-runtime-boundary') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
@@ -186,7 +461,8 @@ jobs:
session-diagnostics-boundary:
name: Critical Quality (session-diagnostics-boundary)
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'session-diagnostics-boundary' }}
needs: quality-shards
if: ${{ needs.quality-shards.outputs.session_diagnostics == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'session-diagnostics-boundary') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
@@ -208,7 +484,8 @@ jobs:
plugin-sdk-reply-runtime:
name: Critical Quality (plugin-sdk-reply-runtime)
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'plugin-sdk-reply-runtime' }}
needs: quality-shards
if: ${{ needs.quality-shards.outputs.plugin_sdk_reply == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'plugin-sdk-reply-runtime') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
@@ -230,7 +507,8 @@ jobs:
provider-runtime-boundary:
name: Critical Quality (provider-runtime-boundary)
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'provider-runtime-boundary' }}
needs: quality-shards
if: ${{ needs.quality-shards.outputs.provider == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'provider-runtime-boundary') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
@@ -252,7 +530,7 @@ jobs:
ui-control-plane:
name: Critical Quality (ui-control-plane)
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' }}
if: ${{ github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.profile == 'all') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
@@ -274,7 +552,7 @@ jobs:
web-media-runtime-boundary:
name: Critical Quality (web-media-runtime-boundary)
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' }}
if: ${{ github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.profile == 'all') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
@@ -296,7 +574,8 @@ jobs:
plugin-boundary:
name: Critical Quality (plugin-boundary)
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' }}
needs: quality-shards
if: ${{ needs.quality-shards.outputs.plugin == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'plugin-boundary') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
@@ -318,7 +597,8 @@ jobs:
plugin-sdk-package-contract:
name: Critical Quality (plugin-sdk-package-contract)
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'plugin-sdk-package-contract' }}
needs: quality-shards
if: ${{ needs.quality-shards.outputs.plugin_sdk_package == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'plugin-sdk-package-contract') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:

View File

@@ -35,8 +35,8 @@ permissions:
security-events: write
jobs:
critical-security:
name: Critical Security (${{ matrix.category }})
security-high:
name: Security High (${{ matrix.category }})
if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'security') }}
runs-on: ${{ matrix.runs_on }}
timeout-minutes: ${{ matrix.timeout_minutes }}
@@ -89,4 +89,4 @@ jobs:
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-security/${{ matrix.category }}"
category: "/codeql-security-high/${{ matrix.category }}"

View File

@@ -54,7 +54,6 @@ jobs:
run_bun_global_install_smoke: ${{ steps.manifest.outputs.run_bun_global_install_smoke }}
target_sha: ${{ steps.manifest.outputs.target_sha }}
dockerfile_image: ${{ steps.manifest.outputs.dockerfile_image }}
dockerfile_cache_scope: ${{ steps.manifest.outputs.dockerfile_cache_scope }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -81,7 +80,6 @@ jobs:
target_sha="$(git rev-parse HEAD)"
owner="$(printf '%s' "${GITHUB_REPOSITORY_OWNER:-openclaw}" | tr '[:upper:]' '[:lower:]')"
dockerfile_image="ghcr.io/${owner}/openclaw-dockerfile-smoke:${target_sha}"
dockerfile_cache_scope="openclaw-dockerfile-smoke"
if [ "$event_name" = "schedule" ]; then
run_bun_global_install_smoke=true
elif [ "$event_name" = "workflow_dispatch" ] || [ "$event_name" = "workflow_call" ]; then
@@ -97,7 +95,6 @@ jobs:
echo "run_bun_global_install_smoke=$run_bun_global_install_smoke"
echo "target_sha=$target_sha"
echo "dockerfile_image=$dockerfile_image"
echo "dockerfile_cache_scope=$dockerfile_cache_scope"
} >> "$GITHUB_OUTPUT"
install-smoke-fast:
@@ -114,7 +111,7 @@ jobs:
ref: ${{ inputs.ref || github.ref }}
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
with:
max-cache-size-mb: 800000
@@ -245,7 +242,7 @@ jobs:
- name: Set up Blacksmith Docker Builder
if: steps.existing.outputs.exists != 'true'
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
with:
max-cache-size-mb: 800000
@@ -254,14 +251,11 @@ jobs:
- name: Build and push root Dockerfile smoke image
if: steps.existing.outputs.exists != 'true'
env:
CACHE_SCOPE: ${{ needs.preflight.outputs.dockerfile_cache_scope }}
IMAGE_REF: ${{ needs.preflight.outputs.dockerfile_image }}
run: |
timeout 45m docker buildx build \
--progress=plain \
--push \
--cache-from "type=gha,scope=${CACHE_SCOPE}" \
--cache-to "type=gha,scope=${CACHE_SCOPE},mode=max" \
--build-arg OPENCLAW_EXTENSIONS=matrix \
-t "$IMAGE_REF" \
-f ./Dockerfile \
@@ -414,7 +408,7 @@ jobs:
run: timeout 300s docker pull "$IMAGE_REF"
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
with:
max-cache-size-mb: 800000
@@ -507,7 +501,7 @@ jobs:
ref: ${{ inputs.ref || github.ref }}
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
with:
max-cache-size-mb: 800000

View File

@@ -36,12 +36,12 @@ jobs:
password: ${{ github.token }}
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
with:
max-cache-size-mb: 800000
- name: Build and push live media runner image
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .github/images/live-media-runner
file: .github/images/live-media-runner/Dockerfile

View File

@@ -105,12 +105,12 @@ jobs:
fetch-depth: 1
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
with:
max-cache-size-mb: 800000
- name: Build Docker E2E image
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: ./scripts/e2e/Dockerfile

View File

@@ -1359,13 +1359,13 @@ jobs:
- name: Setup Docker builder
if: steps.image_exists.outputs.needs_build == '1'
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
with:
max-cache-size-mb: 800000
- name: Build and push bare Docker E2E image
if: steps.plan.outputs.needs_bare_image == '1' && steps.image_exists.outputs.bare_exists != '1'
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: ./scripts/e2e/Dockerfile
@@ -1378,7 +1378,7 @@ jobs:
- name: Build and push functional Docker E2E image
if: steps.plan.outputs.needs_functional_image == '1' && steps.image_exists.outputs.functional_exists != '1'
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: ./scripts/e2e/Dockerfile
@@ -1444,13 +1444,13 @@ jobs:
- name: Setup Docker builder
if: steps.image_exists.outputs.exists != '1'
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
with:
max-cache-size-mb: 800000
- name: Build and push shared live-test image
if: steps.image_exists.outputs.exists != '1'
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: ./Dockerfile

View File

@@ -2,39 +2,68 @@
Docs: https://docs.openclaw.ai
## Unreleased
## 2026.4.29
### Highlights
- Messaging and automation get active-run steering by default, visible-reply enforcement, spawned subagent routing metadata, and opt-in follow-up commitments for heartbeat-delivered reminders. Thanks @vincentkoc, @scoootscooob, @samzong, and @vignesh07.
- Memory grows into a people-aware wiki with provenance views, per-conversation Active Memory filters, partial recall on timeout, and bounded REM preview diagnostics. Thanks @vincentkoc, @quengh, @joeykrug, and @samzong.
- Provider/model coverage expands with NVIDIA onboarding/catalogs plus faster manifest-backed model/auth paths, Bedrock Opus 4.7 thinking parity, and safer Codex/OpenAI-compatible replay and streaming behavior. Thanks @eleqtrizit, @shakkernerd, @prasad-yashdeep, @woodhouse-bot, and @LyHug.
- Gateway and packaged-plugin reliability focuses on slow-host startup, reusable model catalogs, event-loop readiness diagnostics, runtime-dependency repair, stale-session recovery, and version-scoped update caches. Thanks @lpendeavors, @DerFlash, @vincentkoc, @pashpashpash, and @jhsmith409.
- Channel fixes cluster around Slack Block Kit limits, Telegram proxy/webhook/polling/send resilience, Discord startup/rate-limit handling, WhatsApp delivery/liveness, and Microsoft Teams/Matrix/Feishu edge cases. Thanks @slackapi, @SymbolStar, @djgeorg3, @TinyTb, @dseravalli, @nklock, and @alex-xuweilong.
- Security and operations add OpenGrep scanning, sharper GHSA triage policy, safer exec/pairing/owner-scope handling, Docker/onboarding automation, and web-fetch IPv6 ULA opt-in for trusted proxy stacks. Thanks @jesse-merhi, @pgondhi987, @mmaps, @jinjimz, and @jeffrey701.
### Changes
- Models: suppress explicitly configured openai-codex/gpt-5.4-mini inline entries so a stale models config written by `openclaw doctor --fix` cannot bypass the manifest capability block and cause repeated assistant-turn failures when the runtime switches to that model on ChatGPT-backed Codex accounts. Conditional suppressions (e.g. qwen Coding Plan endpoint guards) remain bypassable by explicit user configuration. (#74451) Thanks @0xCyda, @hclsys, and @Marvae.
- Dependencies: refresh workspace runtime, plugin, and tooling packages, including ACP, Pi, AWS SDK, TypeBox, pnpm, oxlint, oxfmt, jsdom, pdfjs, ciao, and tokenjuice, while keeping patched ACP behavior and lint gates current. Thanks @mariozechner.
- Agents/commitments: add opt-in inferred follow-up commitments with hidden batched extraction, per-agent/per-channel scoping, heartbeat delivery, CLI management, a simple `commitments.enabled`/`commitments.maxPerDay` config, and heartbeat-interval due-time clamping so magical check-ins do not echo immediately. (#74189) Thanks @vignesh07.
- Messages/queue: make `steer` drain all pending Pi steering messages at the next model boundary, keep legacy one-at-a-time steering as `queue`, and add a dedicated steering queue docs page. Thanks @vincentkoc.
- Messages/queue: default active-run queueing to `steer` with a 500ms followup fallback debounce, and document the queue modes, precedence, and drop policies on the command queue page. Thanks @vincentkoc.
- Providers/NVIDIA: add the NVIDIA provider with API-key onboarding, setup docs, static catalog metadata, and literal model-ref picker support so NVIDIA hosted models can be selected with their provider prefix intact. (#71204) Thanks @eleqtrizit.
- Memory/wiki: add agent-facing people wiki metadata, canonical aliases, person cards, relationship graphs, privacy/provenance reports, evidence-kind drilldown, and search modes for person lookup, question routing, source evidence, and raw claims. Thanks @vincentkoc.
- Messages: add global `messages.visibleReplies` so operators can require visible output to go through `message(action=send)` for any source chat, while `messages.groupChat.visibleReplies` stays available as the group/channel override. Thanks @scoootscooob.
- Gateway/dev: run `pnpm gateway:watch` through a named tmux session by default, with `gateway:watch:raw` and `OPENCLAW_GATEWAY_WATCH_TMUX=0` for foreground mode, so repeated starts respawn an inspectable watcher without trapping the invoking agent shell. Thanks @vincentkoc.
- Gateway/events: surface `spawnedBy` on subagent chat and agent broadcast payloads so clients can route child session events without an extra session lookup. (#63244) Thanks @samzong.
- Memory/wiki: add agent-facing people wiki metadata, canonical aliases, person cards, relationship graphs, privacy/provenance reports, evidence-kind drilldown, and search modes for person lookup, question routing, source evidence, and raw claims. Thanks @vincentkoc.
- Active Memory: add optional per-conversation `allowedChatIds` and `deniedChatIds` filters so operators can enable recall only for selected direct, group, or channel conversations while keeping broad sessions skipped. (#67977) Thanks @quengh.
- Active Memory: return bounded partial recall summaries when the hidden memory sub-agent times out, including the default temporary-transcript path, so useful recovered context is not discarded. (#73219) Thanks @joeykrug.
- Gateway/memory: add a read-only `doctor.memory.remHarness` RPC so operator clients can preview bounded REM dreaming output without running mutation paths. (#66673) Thanks @samzong.
- Providers/NVIDIA: add the NVIDIA provider with API-key onboarding, setup docs, static catalog metadata, and literal model-ref picker support so NVIDIA hosted models can be selected with their provider prefix intact. (#71204) Thanks @eleqtrizit.
- Models: suppress explicitly configured openai-codex/gpt-5.4-mini inline entries so a stale models config written by `openclaw doctor --fix` cannot bypass the manifest capability block and cause repeated assistant-turn failures when the runtime switches to that model on ChatGPT-backed Codex accounts. Conditional suppressions (e.g. qwen Coding Plan endpoint guards) remain bypassable by explicit user configuration. (#74451) Thanks @0xCyda, @hclsys, and @Marvae.
- Added SQLite-backed plugin state store (`api.runtime.state.openKeyedStore`) for restart-safe keyed registries with TTL, eviction, and automatic plugin isolation. Thanks @amknight.
- Plugin SDK: mark remaining legacy alias exports and diffs tool/config aliases with deprecation metadata, and add a guard so future legacy alias comments require `@deprecated` tags. Thanks @vincentkoc.
- CLI/QR/dependencies: internalize small terminal progress and QR wrapper helpers while keeping the real QR encoder dependency direct, reducing the default runtime dependency graph without changing QR output behavior. Thanks @vincentkoc.
- Dependencies: refresh workspace runtime, plugin, and tooling packages, including ACP, Pi, AWS SDK, TypeBox, pnpm, oxlint, oxfmt, jsdom, pdfjs, ciao, and tokenjuice, while keeping patched ACP behavior and lint gates current. Thanks @mariozechner.
- Gateway/dev: run `pnpm gateway:watch` through a named tmux session by default, with `gateway:watch:raw` and `OPENCLAW_GATEWAY_WATCH_TMUX=0` for foreground mode, so repeated starts respawn an inspectable watcher without trapping the invoking agent shell. Thanks @vincentkoc.
- Gateway/diagnostics: emit an opt-in startup diagnostics timeline that records gateway lifecycle and plugin-load phases behind a config flag, so slow-start diagnosis no longer requires bespoke instrumentation. Thanks @shakkernerd.
- Control UI/i18n: extend the locale registry with new Persian (fa), Dutch (nl), Vietnamese (vi), Italian (it), Arabic (ar), and Thai (th) entries and ship `fa`, `nl`, `vi`, and `zh-TW` docs glossaries, so the docs translation pipeline and the Control UI language picker stay aligned across surfaces. Thanks @vincentkoc.
- Channels: add Yuanbao channel docs entrance so the Tencent Yuanbao bot appears in the channel listing and sidebar navigation. (#73443) Thanks @loongfay.
- Active Memory: add optional per-conversation `allowedChatIds` and `deniedChatIds` filters so operators can enable recall only for selected direct, group, or channel conversations while keeping broad sessions skipped. (#67977) Thanks @quengh.
- Added SQLite-backed plugin state store (`api.runtime.state.openKeyedStore`) for restart-safe keyed registries with TTL, eviction, and automatic plugin isolation. Thanks @amknight.
- Active Memory: return bounded partial recall summaries when the hidden memory sub-agent times out, including the default temporary-transcript path, so useful recovered context is not discarded. (#73219) Thanks @joeykrug.
- Docker setup: add `OPENCLAW_SKIP_ONBOARDING` so automated Docker installs can skip the interactive onboarding step while still applying gateway defaults. (#55518) Thanks @jinjimz.
- Gateway/memory: add a read-only `doctor.memory.remHarness` RPC so operator clients can preview bounded REM dreaming output without running mutation paths. (#66673) Thanks @samzong.
- Gateway/events: surface `spawnedBy` on subagent chat and agent broadcast payloads so clients can route child session events without an extra session lookup. (#63244) Thanks @samzong.
- Security policy: classify media/base64 decode and format-conversion overhead after configured acceptance limits as performance-only for GHSA triage unless a report demonstrates a limit bypass, crash, exhaustion, data exposure, or another boundary bypass. (#74311)
- Security/OpenGrep: add a precise OpenGrep rulepack, source-rule compiler, provenance metadata check, and PR/full scan workflows that validate first-party code and rulepack-only changes while uploading SARIF to GitHub Code Scanning. (#69483) Thanks @jesse-merhi.
- Control UI/i18n: extend the locale registry with new Persian (fa), Dutch (nl), Vietnamese (vi), Italian (it), Arabic (ar), and Thai (th) entries and ship `fa`, `nl`, `vi`, and `zh-TW` docs glossaries, so the docs translation pipeline and the Control UI language picker stay aligned across surfaces. Thanks @vincentkoc.
- Gateway/diagnostics: emit an opt-in startup diagnostics timeline that records gateway lifecycle and plugin-load phases behind a config flag, so slow-start diagnosis no longer requires bespoke instrumentation. Thanks @shakkernerd.
- Agents/commitments: add opt-in inferred follow-up commitments with hidden batched extraction, per-agent/per-channel scoping, heartbeat delivery, CLI management, a simple `commitments.enabled`/`commitments.maxPerDay` config, and heartbeat-interval due-time clamping so magical check-ins do not echo immediately. (#74189) Thanks @vignesh07.
### Fixes
- CLI/agents/status: keep `openclaw agents`, text `agents list`, and plain text `status` on read-only metadata paths so human output no longer preloads plugin runtimes or live channel scans before printing. Fixes #74195. Thanks @NianJiuZst.
- Media: treat legacy Word/OLE attachments with `application/msword` or `application/x-cfb` MIME as binary so printable-looking `.doc` files are not embedded into prompts as text. Fixes #54176; carries forward #54380. Thanks @andyliu.
- Config: accept documented `browser.tabCleanup` keys in strict root config validation, so configured tab cleanup no longer fails before runtime reads it. Fixes #74577. Thanks @lonexreb and @ezdlp.
- Cron: validate disabled job schedule edits before persisting updates, so invalid cron changes no longer partially mutate stored jobs. Fixes #74459. Thanks @yfge.
- Channels/status: keep Telegram, Slack, and Google Chat read-only allowlist/default-target accessors on config-only paths, so status and channel summaries do not resolve SecretRef-backed runtime credentials. Thanks @eusine.
- Active Memory: clarify the deprecated `modelFallbackPolicy` warning and config help so `modelFallback` is described as a chain-resolution last resort, not runtime failover. (#74602) Thanks @jeffrey701.
- Channels/Discord: keep read-only allowlist/default-target accessors from resolving SecretRef-backed bot tokens, so status and channel summaries no longer fail when tokens are only available in gateway runtime. (#74737) Thanks @eusine.
- Gateway/sessions: align session abort wait semantics across `chat`, `agent`, and `sessions` server methods so abort RPCs return after the targeted sessions actually halt instead of resolving early while runs are still draining. (#74751) Thanks @BunsDev.
- Agents/output: drop copied inbound metadata-only assistant replay turns before provider replay instead of synthesizing a placeholder, so Telegram and other channels cannot receive `[assistant copied inbound metadata omitted]` as model output. Fixes #74745. Thanks @adamwdear and @Marvae.
- Doctor/memory: suppress skipped embedding-readiness warnings for key-optional providers such as Ollama and LM Studio while preserving timeout and not-ready diagnostics. Fixes #74608 and #73882. Thanks @hclsys.
- Channels/groups: preserve observe-only turn suppression for prepared dispatch paths and restore deprecated channel turn runtime aliases, so passive observer/group flows stay silent while older plugins keep compiling. Thanks @vincentkoc.
- Feishu: skip empty-text messages (e.g. `{"text":""}`) that carry no media, so no blank user turn is written to the session and downstream LLM providers cannot reject the request with "messages must not be empty". (#74634) Thanks @xdengli and @hclsys.
- Feishu/Bitable: clean up newly created placeholder rows whose fields contain only default empty values while preserving meaningful link, attachment, user, number, boolean, and location values during create-app cleanup. (#73920) Carries forward #40602. Thanks @boat2moon.
- macOS app: keep attach-only mode and the Debug Settings launchd toggle marker-only, so launching with `--attach-only`/`--no-launchd` no longer uninstalls the Gateway LaunchAgent or drops active sessions. (#72174) Thanks @DolencLuka.
- Plugin SDK: restore the deprecated `plugin-sdk/zalouser` command-auth facade so published Lark/Zalo plugins that import it load on current hosts. Fixes #74702. Thanks @Goron01.
- Plugins/runtime-deps: include bundled provider plugins when `models.providers`, auth profiles, agent defaults, or subagent model refs configure that provider, while keeping inactive default-enabled provider plugins out of doctor repair. Refs #74307. Thanks @Skeptomenos.
- Plugins/runtime: resolve relative plugin `api.resolvePath` inputs against the plugin root instead of the host working directory, while keeping absolute and home paths user-resolved. Fixes #74718. Thanks @jimdawdy-hub.
- Plugins/runtime-deps: refresh mirrored root chunks through a temporary file before replacing the active copy, so failed refreshes do not delete chunks that running plugin imports still need. Thanks @shakkernerd.
- Plugins/runtime-deps: prefer `require` conditional exports when building staged dependency aliases, so CommonJS-only plugin runtime deps such as `ws` do not resolve to ESM wrappers under Jiti. Fixes #74547. Thanks @aderius.
- Bonjour/Gateway: cap flapping advertiser restarts in a sliding window, so mDNS probing/name-conflict loops disable discovery instead of churning indefinitely on constrained hosts. Refs #74209 and #74242. Thanks @ndj888 and @Sanjays2402.
- Plugins/runtime-deps: verify staged package entry files before reusing mirrored runtime roots, so browser-control repairs incomplete `ajv`/MCP SDK installs after update instead of failing after restart on a missing `ajv/dist/ajv.js`. Refs #74630. Thanks @spickeringlr.
- Heartbeat: resolve `responsePrefix` template variables with the selected provider, model, and thinking context before delivering alerts or suppressing prefixed `HEARTBEAT_OK` replies. Fixes #43064; repairs #43065; supersedes #46858. Thanks @yweiii and @JunJD.
- Plugins/runtime-deps: resolve bundled runtime dependency aliases with Jiti's sync require conditions so custom plugins using `require("ws")` receive the CommonJS constructor instead of the ESM namespace wrapper. Fixes #74547. Thanks @aderius.
- Memory/LanceDB: show full memory UUIDs in the `memory_forget` candidate list so agents can pass the displayed ID back to targeted deletion without hitting the full-UUID validator. (#66913) Thanks @amittell.
- File-transfer plugin: require canonical read-path preflight authorization for `file.fetch`, fail closed when `dir.fetch` preflight entries are missing, absolute, or traversing, and recheck returned archive entries before handing archive bytes to callers. Carries forward #74134. Thanks @omarshahine.
- Channels/Feishu: retry file-typed iOS video resource downloads as `media` after a Feishu/Lark HTTP 502 and preserve the original 502 when the fallback also fails. Fixes #49855; carries forward #50164 and #73986. Thanks @alex-xuweilong.
- Providers/Amazon Bedrock: expose the full Claude Opus 4.7 thinking profile (`xhigh`, `adaptive`, and `max`) for Bedrock model refs, while keeping Opus/Sonnet 4.6 on adaptive-by-default, so `/think` menus and validation match the Anthropic transport behavior. Fixes #74701. Thanks @prasad-yashdeep, @sparkleHazard, @Sanjays2402, and @hclsys.
- Plugins/tokenjuice: compile the bundled plugin against tokenjuice 0.7.0's published OpenClaw host types instead of a local compatibility shim, so package contract drift fails in OpenClaw validation before release. Thanks @vincentkoc.
@@ -43,6 +72,7 @@ Docs: https://docs.openclaw.ai
- Gateway/startup: bound local discovery advertisement during startup, so a stuck discovery plugin can no longer keep the Gateway from reaching ready. Fixes #73865; refs #74630 and #74633. Thanks @lpendeavors, @moltar-bot, and @Saboor711.
- Gateway/models: serve the last successful model catalog while stale reloads refresh in the background, so Gateway control-plane and OpenAI-compatible requests no longer block behind model-provider rediscovery after model config changes. Refs #74135, #74630, and #74633. Thanks @DerFlash, @moltar-bot, and @Saboor711.
- CLI/status: resolve read-only channel setup runtime fallback from the packaged OpenClaw dist root, so `status --all`, `status --deep`, channel, and doctor paths do not crash when an external channel plugin needs setup metadata. Fixes #74693. Thanks @giangthb.
- SDK/events: keep per-run SDK event streams from surfacing duplicate raw chat projection frames, while normalizing chat-only projection frames and preserving raw access through `rawEvents`. Refs #74704. Thanks @BunsDev.
- Google Meet: block managed Chrome intro/test speech until browser health proves the participant is in-call, and expose `speechReady` diagnostics so login, admission, permission, and audio-bridge blockers no longer look like successful speech. Refs #72478. Thanks @DougButdorf.
- Slack/commands: keep native command argument menus on select controls for encoded choice values up to Slack's option limit and truncate fallback button labels to Slack's button-text limit, so long valid choices no longer render invalid Slack blocks. Thanks @slackapi.
- Agents/Codex: flush accepted debounced steering messages before normal app-server turn cleanup, so inbound follow-ups acknowledged as queued are not dropped when the turn completes before the debounce fires. Thanks @vincentkoc.
@@ -50,15 +80,23 @@ Docs: https://docs.openclaw.ai
- Slack/interactive replies: drop overlong Block Kit button URLs while preserving valid callback values, so malformed link buttons no longer make Slack reject the whole interactive reply. Thanks @slackapi.
- Slack/commands: truncate native command argument-menu confirmation text to Slack's dialog limit, so long plugin arg names no longer make fallback buttons render invalid Block Kit payloads. Thanks @slackapi.
- Slack/exec approvals: cap native approval metadata context to Slack's element and text limits, so large approval details no longer make Slack reject the approval card. Thanks @slackapi.
- Slack/exec approvals: cap native approval update fallback text to Slack's message limit while preserving the rendered approval blocks, so long commands no longer make resolved or expired approval cards stay stale after `chat.update` rejects `msg_too_long`. Thanks @slackapi.
- Slack/commands: cap native command argument-menu fallback rows to Slack's message block limit, so large plugin choice lists no longer make Slack reject the generated menu. Thanks @slackapi.
- Slack/commands: drop fallback command argument buttons whose encoded values exceed Slack's button-value limit, so one oversized plugin choice no longer makes Slack reject the whole menu. Thanks @slackapi.
- Slack/messages: merge message-tool presentation and interactive blocks on Slack sends, so buttons and selects are no longer dropped when a structured message body is also present. Thanks @slackapi.
- Slack/messages: cap Block Kit fallback text to Slack's send limit while preserving the rendered blocks, so long context fallbacks no longer make rich Slack messages fail with `msg_too_long`. Thanks @slackapi.
- Slack/messages: cap Block Kit fallback text on message edits while preserving the rendered blocks, so long context fallbacks no longer make Slack reject `chat.update` calls with `msg_too_long`. Thanks @slackapi.
- Channels/WhatsApp: require Baileys outbound message ids before marking auto-replies delivered, so transcript text and ack reactions no longer make failed group replies look sent. Fixes #49225. Thanks @TinyTb.
- CLI/update: scope packaged Node compile caches by OpenClaw version and install metadata, so global installs no longer reuse stale compiled chunks after package updates. Thanks @pashpashpash.
- Channels/Voice call: keep pre-auth webhook in-flight limiting active when socket remote address metadata is missing, so slow-body requests from stripped-IP proxy paths still share the fallback bucket. (#74453) Thanks @davidangularme.
- Plugin SDK/testing: lazy-load TypeScript from the plugin test-contract runtime and add release checks for critical SDK contract entrypoint imports and bundle size, so published packages fail preflight before shipping ESM-incompatible or oversized contract helpers. Thanks @vincentkoc.
- Channels/Microsoft Teams: treat configured `19:...@thread.tacv2` and legacy `19:...@thread.skype` team/channel IDs as already resolved during startup, avoiding false `channels unresolved` warnings while preserving Graph name lookup for display-name entries. Fixes #74683. Thanks @dseravalli.
- CLI/browser: preserve parent flags while lazy-loading browser subcommands, so `openclaw browser --json open` and `openclaw browser --json tabs` keep machine-readable output after reparsing. Fixes #74574. Thanks @devintegeritsm.
- Exec/elevated: preserve `turnSourceChannel` as `messageProvider` on approval-followup runs so `tools.elevated.allowFrom.<provider>` checks no longer fail with `provider=null` after the user approves an async elevated command. Fixes #74646. Thanks @xhd2015.
- Plugins/runtime-deps: add `openclaw plugins deps` inspection and repair with script-free package-manager defaults shared across plugin installers, so operators can repair missing bundled runtime deps without corrupting JSON output or blocking unrelated conflict-free deps. Thanks @vincentkoc.
- Agents/output: strip internal `[tool calls omitted]` replay placeholders from user-facing replies while preserving visible reply whitespace. Fixes #74573. Thanks @blaspat.
- Providers/Google Vertex: route authorized_user ADC credentials through OpenClaw's REST transport so Docker installs using gcloud application-default credentials no longer crash in the Google SDK before requests are sent. Fixes #74628. Thanks @frankhal2001-design.
- ACP/resolver: fall through to thread-bound session resolution when an explicit `--session` token cannot be resolved while preserving the bad-token diagnostic when no thread binding exists, so Discord slash commands that auto-fill the current thread ID as the positional ACP target no longer return "Unable to resolve session target" errors. Fixes #66299. Thanks @hclsys, @kindomLee, and @martingarramon.
- Agents/sessions: emit a terminal lifecycle backstop when embedded timeout/error turns return without `agent_end`, so Gateway sessions no longer stay stuck in `running` after failover surfaces a timeout. Fixes #74607. Thanks @millerc79.
- Gateway/diagnostics: include stuck-session reason hints and recovery skip causes in warnings, so operators can tell whether a lane is waiting on active work, queued work, or stale bookkeeping. Thanks @vincentkoc.
- Agents/Codex: bound embedded-run cleanup, trajectory flushing, and command-lane task timeouts after runtime failures, so Discord and other chat sessions return to idle instead of staying stuck in processing. Thanks @vincentkoc.
@@ -71,6 +109,7 @@ Docs: https://docs.openclaw.ai
- Sandbox/Docker: tolerate Docker daemon unavailability when sandbox mode is off, so doctor and preflight checks no longer fail on installs that do not run the Docker daemon. Fixes #73671. Thanks @kaseonedge.
- Control UI/mobile: persist mobile chat settings through Lit-managed state and route mobile navigation through the same view-state path so chat panel toggles survive transitions on small viewports. Thanks @BunsDev.
- Control UI/exports: align sidebar trigger affordances across the resizable divider, mobile layout, and exported-HTML transcript template so the sidebar toggle and exported transcript sidebar render with consistent hit areas and styling. Thanks @BunsDev.
- Control UI/chat: disable the page refresh affordance while a chat run is active so accidental refreshes do not abort an in-flight reply. Thanks @BunsDev.
- Media: include redacted per-attempt resize failures and resolved model input capabilities in vision-pipeline errors so ARM64 image failures are diagnosable without closing the remaining routing investigation. Refs #74552. Thanks @1yihui.
- Control UI/i18n: route zh-CN agent, debug, channel-refresh, and exec-approval copy through the locale source while preserving the English `Cron Jobs` agent tab label and the security-audit command styling. Carries forward #39692 repair context. Thanks @hepeng154833488 and @vincentkoc.
- Auto-reply: honor explicit `silentReply.direct: "allow"` for clean empty or reasoning-only direct chat turns while keeping the default direct-chat empty-response guard conservative. Fixes #74409. Thanks @jesuskannolis.
@@ -209,6 +248,7 @@ Docs: https://docs.openclaw.ai
- CLI/status: fall back to a bounded local `status` RPC when loopback detail probes time out or report unknown capability, so reachable local gateways are no longer marked unreachable by slow read diagnostics. Fixes #73535; refs #48360, #62762, #51357, and #42019. Thanks @RacecarGuy, @justinschille, @DJBlackhawk, @tianyaqpzm, and @0xrsydn.
- CLI/gateway: reuse cached paired-device auth during `gateway probe` and report post-connect diagnostic failures as degraded reachability, so healthy local gateways are no longer marked unreachable after loopback auth or read timeouts. Fixes #48360. Thanks @RacecarGuy.
- Channels/Discord: give Discord Gateway WebSocket handshakes a 30s timeout so stalled TLS/network transitions emit an error and Carbon can continue its reconnect loop instead of leaving the bot silent until restart. Refs #50046. Thanks @codexGW.
- Mattermost/WebSocket: send protocol ping/pong keepalives and terminate stale sessions when pongs stop arriving, so silent TCP drops reconnect instead of leaving monitoring idle. Fixes #41837; carries forward #57621; refs #50138, #44160, and #51104. Thanks @JasonWang1124.
- Channels/Telegram: suppress standalone failed edit/write warning payloads when a user-facing assistant error reply already covers the turn, while keeping unresolved mutating failures visible behind success-looking or suppressed-error replies. Fixes #39631; refs #73750; carries forward #39636 and #39717; leaves #39406 for configurable delivery policy. Thanks @Bartok9 and @Bortlesboat.
- Control UI/agents: persist the Set Default action through `agents.list[].default` instead of writing the unsupported `agents.defaultId` field, so saved default-agent changes survive config validation. Fixes #65565; carries forward #72585. Thanks @luyao618.
- NVIDIA/NIM: persist the `NVIDIA_API_KEY` provider marker and mark bundled NVIDIA Chat Completions models as string-content compatible, so NIM models load from `models.json` and OpenAI-compatible subagent calls send plain text content. Fixes #73013 and #50107; refs #73014. Thanks @bautrey, @iot2edge, @ifearghal, and @futhgar.
@@ -256,6 +296,7 @@ Docs: https://docs.openclaw.ai
- Pairing/doctor: bootstrap `commands.ownerAllowFrom` from the first approved DM pairing when no command owner exists, and have doctor explain missing owners so privileged slash commands are not accidentally unusable after onboarding. Thanks @pashpashpash.
- Telegram/exec: infer native exec approvers from `commands.ownerAllowFrom` and auto-enable the Telegram approval client when an owner is resolvable, so owner-only commands such as `/diagnostics` can be approved in Telegram without duplicate per-channel approver config. Thanks @pashpashpash.
- Auto-reply/session: carry the tail of user/assistant turns into the freshly-rotated transcript on silent in-reply session resets (compaction failure, role-ordering conflict) so direct-chat continuity survives the rebind. Fixes #70853. (#70898) Thanks @neeravmakwana.
- Skills: load grouped skill directories such as `skills/<group>/<skill>/SKILL.md` from configured skill roots while keeping grouped discovery capped for large directories. Fixes #56915. (#72534) Thanks @ottodeng, @MoerAI, and @i010542.
- Config: skip malformed non-string `env.vars` entries before env-reference checks, so config loading no longer crashes on JSON values like numbers or booleans. (#42402) Thanks @MiltonHeYan.
- Docker Compose: default missing config and workspace bind mounts to `${HOME:-/tmp}/.openclaw` so manual compose runs do not create invalid empty-source volume specs. (#64485) Thanks @jlapenna.
- Agents/context engines: preserve the child agent's configured `agentDir` when subagent cleanup re-resolves a context engine, so `onSubagentEnded` hooks keep operating on the correct per-agent state. (#67243) Thanks @jarimustonen.
@@ -275,6 +316,8 @@ Docs: https://docs.openclaw.ai
- Commands: keep channel-prefixed owner allowlist entries scoped to matching providers so webchat command contexts cannot inherit external channel owners. Thanks @zsxsoft.
- Auth/device pairing: bound bootstrap handoff token issuance, redemption, and approved pairing baselines to the documented per-role scope allowlist, so bootstrap approvals cannot persistently grant `operator.admin`, `operator.pairing`, or `node.exec` scopes. Thanks @eleqtrizit.
- Providers/GitHub Copilot: support the GUI/RPC wizard device-code auth flow so onboarding from non-TTY clients (gateway RPC bridge, GUI wizards) completes instead of returning empty profiles. Dangerous-state handling now distinguishes `access_denied` and `expired_token` from transport errors. (#73290) Thanks @indierawk2k2.
- Installer/Linux: warn before switching an unwritable npm global prefix to `~/.npm-global`, then tell users to run future global updates with `npm i -g openclaw@latest` without `sudo` so npm keeps using the redirected user prefix. Fixes #44365; carries forward #50479. Thanks @Sayeem3051.
- Gateway/plugins: enable the native `require()` fast path on Windows for bundled plugin modules so plugin loading uses `require()` instead of Jiti's transform pipeline, reducing startup from ~39s to ~2s on typical 6-plugin setups. Fixes #68656. (#74173) Thanks @galiniliev.
## 2026.4.27

View File

@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
FROM debian:bookworm-slim@sha256:4724b8cc51e33e398f0e2e15e18d5ec2851ff0c2280647e1310bc1642182655d
FROM debian:bookworm-slim@sha256:f9c6a2fd2ddbc23e336b6257a5245e31f996953ef06cd13a59fa0a1df2d5c252
ENV DEBIAN_FRONTEND=noninteractive

View File

@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
FROM debian:bookworm-slim@sha256:4724b8cc51e33e398f0e2e15e18d5ec2851ff0c2280647e1310bc1642182655d
FROM debian:bookworm-slim@sha256:f9c6a2fd2ddbc23e336b6257a5245e31f996953ef06cd13a59fa0a1df2d5c252
ENV DEBIAN_FRONTEND=noninteractive

View File

@@ -1,5 +1,5 @@
{
"originHash" : "e6910acc97de62dc423c0a391985c1c2f28207951e356081539abde41f9ffc72",
"originHash" : "646c710cf04fdf9e6c6ca935f3184924db3397a816848a7f8a8a3c10a4d8e9c8",
"pins" : [
{
"identity" : "commander",
@@ -15,8 +15,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax.git",
"state" : {
"revision" : "0687f71944021d616d34d922343dcef086855920",
"version" : "600.0.1"
"revision" : "9de99a78f099e59caf2b2beec65a4c45d54b2081",
"version" : "603.0.1"
}
},
{
@@ -24,8 +24,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-testing",
"state" : {
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
"version" : "0.99.0"
"revision" : "937120cbc281cf29727fdfb8734482158508b4fc",
"version" : "6.3.1"
}
}
],

View File

@@ -14,7 +14,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.2"),
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
.package(url: "https://github.com/apple/swift-testing", from: "6.3.1"),
],
targets: [
.target(

View File

@@ -92,14 +92,6 @@ struct DebugSettings: View {
self.launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
return
}
if newValue {
Task {
_ = await GatewayLaunchAgentManager.set(
enabled: false,
bundlePath: Bundle.main.bundlePath,
port: GatewayEnvironment.gatewayPort())
}
}
}
Text(

View File

@@ -5,7 +5,12 @@ enum GatewayLaunchAgentManager {
private static let disableLaunchAgentMarker = ".openclaw/disable-launchagent"
private static var disableLaunchAgentMarkerURL: URL {
FileManager().homeDirectoryForCurrentUser
#if DEBUG
if let testingDisableLaunchAgentMarkerURL {
return testingDisableLaunchAgentMarkerURL
}
#endif
return FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(self.disableLaunchAgentMarker)
}
@@ -19,6 +24,10 @@ enum GatewayLaunchAgentManager {
return false
}
static func applyAttachOnlyRuntimeOverride() -> String? {
self.setLaunchAgentWriteDisabled(true)
}
static func setLaunchAgentWriteDisabled(_ disabled: Bool) -> String? {
let marker = self.disableLaunchAgentMarkerURL
if disabled {
@@ -144,6 +153,15 @@ extension GatewayLaunchAgentManager {
timeout: Double,
quiet: Bool) async -> CommandResult
{
#if DEBUG
if self.testingInterceptDaemonCommands {
self.testingDaemonCommandCalls.append(args)
return CommandResult(
success: true,
payload: Data("{\"ok\":true}".utf8),
message: nil)
}
#endif
let command = CommandResolver.openclawCommand(
subcommand: "gateway",
extraArgs: self.withJsonFlag(args),
@@ -187,4 +205,26 @@ extension GatewayLaunchAgentManager {
private static func summarize(_ text: String) -> String? {
TextSummarySupport.summarizeLastLine(text)
}
#if DEBUG
private nonisolated(unsafe) static var testingDisableLaunchAgentMarkerURL: URL?
private nonisolated(unsafe) static var testingInterceptDaemonCommands = false
private nonisolated(unsafe) static var testingDaemonCommandCalls: [[String]] = []
static func setTestingDisableLaunchAgentMarkerURL(_ url: URL?) {
self.testingDisableLaunchAgentMarkerURL = url
}
static func setTestingInterceptDaemonCommands(_ intercept: Bool) {
self.testingInterceptDaemonCommands = intercept
}
static func clearTestingDaemonCommandCalls() {
self.testingDaemonCommandCalls.removeAll(keepingCapacity: false)
}
static func testingDaemonCommandCallsSnapshot() -> [[String]] {
self.testingDaemonCommandCalls
}
#endif
}

View File

@@ -98,16 +98,10 @@ struct OpenClawApp: App {
private static func applyAttachOnlyOverrideIfNeeded() {
let args = CommandLine.arguments
guard args.contains("--attach-only") || args.contains("--no-launchd") else { return }
if let error = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(true) {
if let error = GatewayLaunchAgentManager.applyAttachOnlyRuntimeOverride() {
Self.logger.error("attach-only flag failed: \(error, privacy: .public)")
return
}
Task {
_ = await GatewayLaunchAgentManager.set(
enabled: false,
bundlePath: Bundle.main.bundlePath,
port: GatewayEnvironment.gatewayPort())
}
Self.logger.info("attach-only flag enabled")
}

View File

@@ -3,6 +3,29 @@ import Testing
@testable import OpenClaw
struct GatewayLaunchAgentManagerTests {
@Test func `attach only runtime override does not uninstall gateway launch agent`() throws {
let dir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-attach-only-\(UUID().uuidString)", isDirectory: true)
let marker = dir.appendingPathComponent("disable-launchagent")
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager().removeItem(at: dir) }
defer {
GatewayLaunchAgentManager.setTestingDisableLaunchAgentMarkerURL(nil)
GatewayLaunchAgentManager.setTestingInterceptDaemonCommands(false)
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
}
GatewayLaunchAgentManager.setTestingDisableLaunchAgentMarkerURL(marker)
GatewayLaunchAgentManager.setTestingInterceptDaemonCommands(true)
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
let error = GatewayLaunchAgentManager.applyAttachOnlyRuntimeOverride()
#expect(error == nil)
#expect(FileManager().fileExists(atPath: marker.path))
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty)
}
@Test func `launch agent plist snapshot parses args and env`() throws {
let url = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist")

View File

@@ -1,4 +1,4 @@
7bf720f6d9040c53323553b1bd351f688137c6b352c4cf2acfd7f7d252644b38 config-baseline.json
ab9a004ec78ed51e646be29eb10aa6700de1d47fee77331a85ca5e2cd15b6e93 config-baseline.core.json
c3bcb3a3da46bbbe15a7798869911cab109df950ee51c79fd86c96bb809dfdf1 config-baseline.json
8f573caa7f4cf01ae9d4805d3d14e1ba6772f651f6da182baaf2b469592749a4 config-baseline.core.json
92712871defa92eeda8161b516db85574681f2b70678b940508a808b987aeae2 config-baseline.channel.json
c4231c2194206547af8ad94342dc00aadb734f43cb49cc79d4c46bdbb80c3f95 config-baseline.plugin.json
aca3215b7382af82b5060d73c631a7f82661c6e99193fa5eb1c5b4b499fb657b config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
d26a70c9ea3bd277135a1712556f07195fb464b5cd846d04f18c2166c319a73d plugin-sdk-api-baseline.json
9fe2cb122fb3de17eaaf54c7768f268aa689063cf9091bd4b0be9422550a70a8 plugin-sdk-api-baseline.jsonl
dd840b7c222ca003aa5336aabff8a126e3e254474941ddab93165e0e44944ffa plugin-sdk-api-baseline.json
443878722940029e4ae5220f3c23ffc321559b73848f6a7a3f4cab98c076924e plugin-sdk-api-baseline.jsonl

File diff suppressed because one or more lines are too long

View File

@@ -265,7 +265,7 @@ openclaw plugins deps --prune
openclaw plugins deps --json
```
`plugins deps` inspects the packaged runtime dependency stage for OpenClaw-owned bundled plugins. It is not the install/update path for third-party npm or ClawHub plugins.
`plugins deps` inspects the packaged runtime dependency stage for OpenClaw-owned bundled plugins selected by plugin config, enabled/configured channels, configured model providers, or bundled manifest defaults. It is not the install/update path for third-party npm or ClawHub plugins.
Use `--repair` when a packaged install reports missing bundled runtime dependencies during Gateway startup or `plugins doctor`. Repair installs only missing enabled bundled-plugin deps with lifecycle scripts disabled. Use `--prune` to remove stale unknown external runtime-dependency roots left behind by older packaged layouts.

View File

@@ -333,7 +333,7 @@ That stages grounded durable candidates into the short-term dreaming store while
When sandboxing is enabled, doctor checks Docker images and offers to build or switch to legacy names if the current image is missing.
</Accordion>
<Accordion title="7b. Bundled plugin runtime deps">
Doctor verifies runtime dependencies only for bundled plugins that are active in the current config or enabled by their bundled manifest default, for example `plugins.entries.discord.enabled: true`, legacy `channels.discord.enabled: true`, or a default-enabled bundled provider. If any are missing, doctor reports the packages and installs them in `openclaw doctor --fix` / `openclaw doctor --repair` mode. External plugins still use `openclaw plugins install` / `openclaw plugins update`; doctor does not install dependencies for arbitrary plugin paths.
Doctor verifies runtime dependencies only for bundled plugins that are active in the current config or enabled by their bundled manifest default, for example `plugins.entries.discord.enabled: true`, legacy `channels.discord.enabled: true`, configured `models.providers.*` / agent model refs, or a default-enabled bundled plugin without provider ownership. If any are missing, doctor reports the packages and installs them in `openclaw doctor --fix` / `openclaw doctor --repair` mode. External plugins still use `openclaw plugins install` / `openclaw plugins update`; doctor does not install dependencies for arbitrary plugin paths.
During doctor repair, bundled runtime-dependency npm installs report spinner progress in TTY sessions and periodic line progress in piped/headless output. The Gateway and local CLI can also repair active bundled plugin runtime dependencies on demand before importing a bundled plugin. These installs are scoped to the plugin runtime install root, run with scripts disabled, do not write a package lock, and are guarded by an install-root lock so concurrent CLI or Gateway starts do not mutate the same `node_modules` tree at the same time.

View File

@@ -202,6 +202,12 @@ Dangerous or privacy-heavy commands such as `camera.snap`, `camera.clip`, and
`gateway.nodes.allowCommands`. `gateway.nodes.denyCommands` always wins over
defaults and extra allowlist entries.
Plugin-owned node commands can add a Gateway node-invoke policy. That policy
runs after the allowlist check and before forwarding to the node, so raw
`node.invoke`, CLI helpers, and dedicated agent tools share the same plugin
permission boundary. Dangerous plugin node commands still require explicit
`gateway.nodes.allowCommands` opt-in.
After a node changes its declared command list, reject the old device pairing
and approve the new request so the gateway stores the updated command snapshot.

View File

@@ -178,7 +178,9 @@ Provider and channel execution paths must use the active runtime config snapshot
});
```
Inside the Gateway this runtime is in-process. In plugin CLI commands it calls the configured Gateway over RPC, so commands such as `openclaw googlemeet recover-tab` can inspect paired nodes from the terminal. Node commands still go through normal Gateway node pairing, command allowlists, and node-local command handling.
Inside the Gateway this runtime is in-process. In plugin CLI commands it calls the configured Gateway over RPC, so commands such as `openclaw googlemeet recover-tab` can inspect paired nodes from the terminal. Node commands still go through normal Gateway node pairing, command allowlists, plugin node-invoke policies, and node-local command handling.
Plugins that expose dangerous node-host commands should register a node-invoke policy with `api.registerNodeInvokePolicy(...)`. The policy runs in the Gateway after command allowlist checks and before the command is forwarded to the node, so direct `node.invoke` calls and higher-level plugin tools share the same enforcement path.
</Accordion>
<Accordion title="api.runtime.tasks.managedFlows">

View File

@@ -86,6 +86,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| `plugin-sdk/direct-dm` | Shared direct-DM auth/guard helpers |
| `plugin-sdk/discord` | Deprecated Discord compatibility facade for published `@openclaw/discord@2026.3.13` and tracked owner compatibility; new plugins should use generic channel SDK subpaths |
| `plugin-sdk/telegram-account` | Deprecated Telegram account-resolution compatibility facade for tracked owner compatibility; new plugins should use injected runtime helpers or generic channel SDK subpaths |
| `plugin-sdk/zalouser` | Deprecated Zalo Personal compatibility facade for published Lark/Zalo packages that still import sender command authorization; new plugins should use `plugin-sdk/command-auth` |
| `plugin-sdk/interactive-runtime` | Semantic message presentation, delivery, and legacy interactive reply helpers. See [Message Presentation](/plugins/message-presentation) |
| `plugin-sdk/channel-inbound` | Compatibility barrel for inbound debounce, mention matching, mention-policy helpers, and envelope helpers |
| `plugin-sdk/channel-inbound-debounce` | Narrow inbound debounce helpers |

View File

@@ -130,6 +130,9 @@ Native `openclaw skills install` installs into the active workspace
`./skills` under your current working directory (or falls back to the
configured OpenClaw workspace). OpenClaw picks that up as
`<workspace>/skills` on the next session.
Configured skill roots also support one grouping level, such as
`skills/<group>/<skill>/SKILL.md`, so related third-party skills can be
kept under a shared folder without broad recursive scanning.
ClawHub skill pages expose the latest security scan state before install,
with scanner detail pages for VirusTotal, ClawScan, and static analysis.

View File

@@ -1384,6 +1384,22 @@ describe("active-memory plugin", () => {
expect(api.logger.warn).toHaveBeenCalledWith(
expect.stringContaining("config.modelFallbackPolicy is deprecated"),
);
// #74587: deprecation warning must spell out the chain-resolution
// semantics so operators don't read it as a promise of runtime failover.
// The previous wording ("set config.modelFallback if you want a fallback
// model") cost real users hours of debug time before they hit the source
// and saw `getModelRef` only walks candidates once.
const warnCalls = (api.logger.warn as ReturnType<typeof vi.fn>).mock.calls;
const deprecationMessage = warnCalls
.map(([first]) => (typeof first === "string" ? first : ""))
.find((message) => message.includes("config.modelFallbackPolicy is deprecated"));
expect(deprecationMessage).toBeDefined();
// Positive: the warning describes chain-resolution last-resort behavior.
expect(deprecationMessage).toContain("chain-resolution");
expect(deprecationMessage).toContain("last-resort");
// Negative: the warning explicitly disclaims runtime failover, since
// that's the wrong mental model the previous wording invited.
expect(deprecationMessage).toMatch(/NOT a runtime failover/i);
});
it("does not use a built-in fallback model even when default-remote is configured", async () => {

View File

@@ -2431,8 +2431,19 @@ export default definePluginEntry({
let config = normalizePluginConfig(api.pluginConfig);
const warnDeprecatedModelFallbackPolicy = (pluginConfig: unknown) => {
if (hasDeprecatedModelFallbackPolicy(pluginConfig)) {
// Wording matters here: the previous text ("set config.modelFallback
// explicitly if you want a fallback model") read naturally as runtime
// failover (model A errors → switch to model B), but `getModelRef`
// only consults `modelFallback` as the *last candidate* in the
// resolution chain after `config.model`, the current run's model,
// and the agent's configured default have all resolved to nothing.
// Surface the chain-resolution semantics directly so operators
// don't waste debug cycles assuming runtime failover (#74587).
api.logger.warn?.(
"active-memory: config.modelFallbackPolicy is deprecated and no longer changes runtime behavior; set config.modelFallback explicitly if you want a fallback model",
"active-memory: config.modelFallbackPolicy is deprecated and no longer changes runtime behavior. " +
"config.modelFallback is a chain-resolution last-resort (consulted only when config.model, " +
"the current run's model, and the agent's configured default all resolve to nothing) — " +
"it is NOT a runtime failover that substitutes a different model when the resolved model errors out.",
);
}
};

View File

@@ -99,7 +99,7 @@
},
"modelFallbackPolicy": {
"label": "Model Fallback Policy",
"help": "Deprecated compatibility field. Active Memory no longer uses a built-in fallback model; set modelFallback explicitly if you want a fallback."
"help": "Deprecated compatibility field. modelFallback is only the chain-resolution last resort when no explicit plugin model, session model, or agent primary model resolves; it is not runtime failover."
},
"allowedChatTypes": {
"label": "Allowed Chat Types",

View File

@@ -412,6 +412,58 @@ describe("amazon-bedrock provider plugin", () => {
).toEqual({ maxTokens: 12 });
});
it("preserves Bedrock Opus 4.7 max thinking in the final payload", async () => {
const provider = await registerSingleProviderPlugin(amazonBedrockPlugin);
const wrapped = provider.wrapStreamFn?.({
provider: "amazon-bedrock",
modelId: "us.anthropic.claude-opus-4-7",
streamFn: spyStreamFn,
thinkingLevel: "max",
} as never);
const result = wrapped?.(
{
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
id: "us.anthropic.claude-opus-4-7",
} as never,
{ messages: [] } as never,
{ reasoning: "xhigh" } as never,
) as Record<string, unknown> | undefined;
const payload = {
additionalModelRequestFields: {
thinking: { type: "adaptive" },
output_config: { effort: "xhigh" },
},
};
await (result?.onPayload as ((p: Record<string, unknown>) => unknown) | undefined)?.(payload);
expect(payload.additionalModelRequestFields.output_config).toEqual({ effort: "max" });
});
it("keeps Bedrock Opus 4.7 xhigh thinking distinct from max", async () => {
const provider = await registerSingleProviderPlugin(amazonBedrockPlugin);
const wrapped = provider.wrapStreamFn?.({
provider: "amazon-bedrock",
modelId: "us.anthropic.claude-opus-4-7",
streamFn: spyStreamFn,
thinkingLevel: "xhigh",
} as never);
const result = wrapped?.(
{
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
id: "us.anthropic.claude-opus-4-7",
} as never,
{ messages: [] } as never,
{ reasoning: "xhigh" } as never,
) as Record<string, unknown> | undefined;
expect(result).not.toHaveProperty("onPayload");
});
it("classifies nested Bedrock deprecated-temperature validation as format failover", async () => {
const provider = await registerSingleProviderPlugin(amazonBedrockPlugin);

View File

@@ -285,6 +285,22 @@ function injectBedrockCachePoints(
}
}
function patchOpus47MaxThinkingEffort(payload: Record<string, unknown>): void {
const fieldsValue = payload.additionalModelRequestFields;
const fields =
fieldsValue && typeof fieldsValue === "object" && !Array.isArray(fieldsValue)
? (fieldsValue as Record<string, unknown>)
: {};
const outputConfigValue = fields.output_config;
const outputConfig =
outputConfigValue && typeof outputConfigValue === "object" && !Array.isArray(outputConfigValue)
? (outputConfigValue as Record<string, unknown>)
: {};
outputConfig.effort = "max";
fields.output_config = outputConfig;
payload.additionalModelRequestFields = fields;
}
export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
// Keep registration-local constants inside the function so partial module
// initialization during test bootstrap cannot trip TDZ reads.
@@ -441,7 +457,7 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
},
resolveConfigApiKey: ({ env }) => resolveBedrockConfigApiKey(env),
...anthropicByModelReplayHooks,
wrapStreamFn: ({ modelId, config, model, streamFn }) => {
wrapStreamFn: ({ modelId, config, model, streamFn, thinkingLevel }) => {
const currentGuardrail = resolveCurrentPluginConfig(config)?.guardrail;
// Apply cache + guardrail wrapping.
const wrapped =
@@ -452,12 +468,13 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
const mayNeedCacheInjection =
isBedrockAppInferenceProfile(modelId) && !piAiWouldInjectCachePoints(modelId);
const shouldOmitTemperature = isOpus47BedrockModelRef(modelId);
const shouldPatchMaxThinking = shouldOmitTemperature && thinkingLevel === "max";
// For known Anthropic models (heuristic match), enable injection immediately.
// For opaque profile IDs, we'll resolve via GetInferenceProfile on first call.
const heuristicMatch = needsCachePointInjection(modelId);
if (!region && !mayNeedCacheInjection && !shouldOmitTemperature) {
if (!region && !mayNeedCacheInjection && !shouldOmitTemperature && !shouldPatchMaxThinking) {
return wrapped;
}
@@ -471,8 +488,24 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
Object.assign({}, options, region ? { region } : {}),
);
const originalOnPayload = merged.onPayload as
| ((payload: unknown, model: unknown) => unknown)
| undefined;
if (!mayNeedCacheInjection) {
return underlying(streamModel, context, merged);
return underlying(streamModel, context, {
...merged,
...(shouldPatchMaxThinking
? {
onPayload: (payload: unknown, payloadModel: unknown) => {
if (payload && typeof payload === "object") {
patchOpus47MaxThinkingEffort(payload as Record<string, unknown>);
}
return originalOnPayload?.(payload, payloadModel);
},
}
: {}),
});
}
// Use the cacheRetention from options if explicitly set.
@@ -485,10 +518,6 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
// want caching enabled, so defaulting to "short" is the safer behavior.
const cacheRetention =
typeof merged.cacheRetention === "string" ? merged.cacheRetention : "short";
const originalOnPayload = merged.onPayload as
| ((payload: unknown, model: unknown) => unknown)
| undefined;
if (heuristicMatch) {
// Fast path: ARN heuristic already identified this as Claude, but the
// concrete target may still need profile traits for Opus 4.7 payloads.
@@ -499,6 +528,9 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
if (payload && typeof payload === "object") {
const payloadRecord = payload as Record<string, unknown>;
injectBedrockCachePoints(payloadRecord, cacheRetention);
if (shouldPatchMaxThinking) {
patchOpus47MaxThinkingEffort(payloadRecord);
}
if (mayNeedTemperatureTrait) {
const traits = await resolveAppProfileTraits(modelId, region);
if (traits.omitTemperature) {
@@ -522,6 +554,9 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
if (traits.cacheEligible) {
injectBedrockCachePoints(payloadRecord, cacheRetention);
}
if (shouldPatchMaxThinking) {
patchOpus47MaxThinkingEffort(payloadRecord);
}
if (traits.omitTemperature) {
omitDeprecatedOpus47PayloadTemperature(payloadRecord);
}

View File

@@ -4,7 +4,7 @@ import {
resolveEnvelopeFormatOptions,
} from "openclaw/plugin-sdk/channel-inbound";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { runInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { createNonExitingRuntime, logVerbose } from "openclaw/plugin-sdk/runtime-env";
@@ -270,83 +270,97 @@ export async function dispatchDiscordComponentEvent(params: {
startId: params.replyToId,
});
await runPreparedInboundReplyTurn({
await runInboundReplyTurn({
channel: "discord",
accountId,
routeSessionKey: sessionKey,
storePath,
ctxPayload,
recordInboundSession,
record: {
updateLastRoute: interactionCtx.isDirectMessage
? {
sessionKey: route.mainSessionKey,
channel: "discord",
to:
resolveDiscordComponentOriginatingTo(interactionCtx) ??
`user:${interactionCtx.userId}`,
accountId,
mainDmOwnerPin: pinnedMainDmOwner
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: interactionCtx.userId,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err) => {
logVerbose(`discord: failed updating component session meta: ${String(err)}`);
},
},
runDispatch: () =>
dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: ctx.cfg,
replyOptions: { onModelSelected },
dispatcherOptions: {
...replyPipeline,
humanDelay: resolveHumanDelayConfig(ctx.cfg, agentId),
deliver: async (payload) => {
const replyToId = replyReference.use();
await deliverDiscordReply({
cfg: ctx.cfg,
replies: [payload],
target: deliverTarget,
token,
accountId,
rest: interaction.client.rest,
runtime,
replyToId,
replyToMode,
textLimit,
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({
cfg: ctx.cfg,
discordConfig: ctx.discordConfig,
raw: interaction,
adapter: {
ingest: () => ({
id: interaction.id,
rawText: ctxPayload.RawBody ?? "",
textForAgent: ctxPayload.BodyForAgent,
textForCommands: ctxPayload.CommandBody,
raw: interaction,
}),
resolveTurn: () => ({
channel: "discord",
accountId,
routeSessionKey: sessionKey,
storePath,
ctxPayload,
recordInboundSession,
record: {
updateLastRoute: interactionCtx.isDirectMessage
? {
sessionKey: route.mainSessionKey,
channel: "discord",
to:
resolveDiscordComponentOriginatingTo(interactionCtx) ??
`user:${interactionCtx.userId}`,
accountId,
}),
tableMode,
chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId),
mediaLocalRoots,
});
replyReference.markSent();
},
onReplyStart: async () => {
try {
const { sendTyping } = await loadTypingRuntime();
await sendTyping({ rest: feedbackRest, channelId: typingChannelId });
} catch (err) {
logVerbose(`discord: typing failed for component reply: ${String(err)}`);
}
},
onError: (err) => {
logError(`discord component dispatch failed: ${String(err)}`);
mainDmOwnerPin: pinnedMainDmOwner
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: interactionCtx.userId,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err) => {
logVerbose(`discord: failed updating component session meta: ${String(err)}`);
},
},
runDispatch: () =>
dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: ctx.cfg,
replyOptions: { onModelSelected },
dispatcherOptions: {
...replyPipeline,
humanDelay: resolveHumanDelayConfig(ctx.cfg, agentId),
deliver: async (payload) => {
const replyToId = replyReference.use();
await deliverDiscordReply({
cfg: ctx.cfg,
replies: [payload],
target: deliverTarget,
token,
accountId,
rest: interaction.client.rest,
runtime,
replyToId,
replyToMode,
textLimit,
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({
cfg: ctx.cfg,
discordConfig: ctx.discordConfig,
accountId,
}),
tableMode,
chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId),
mediaLocalRoots,
});
replyReference.markSent();
},
onReplyStart: async () => {
try {
const { sendTyping } = await loadTypingRuntime();
await sendTyping({ rest: feedbackRest, channelId: typingChannelId });
} catch (err) {
logVerbose(`discord: typing failed for component reply: ${String(err)}`);
}
},
onError: (err) => {
logError(`discord component dispatch failed: ${String(err)}`);
},
},
}),
}),
},
});
}

View File

@@ -15,13 +15,12 @@ import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import {
hasFinalInboundReplyDispatch,
runPreparedInboundReplyTurn,
runInboundReplyTurn,
} from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
@@ -480,109 +479,135 @@ export async function processDiscordMessage(
await settleDispatchBeforeStart();
return;
}
const preparedResult = await runPreparedInboundReplyTurn({
const preparedResult = await runInboundReplyTurn({
channel: "discord",
accountId: route.accountId,
routeSessionKey: persistedSessionKey,
storePath: turn.storePath,
ctxPayload,
recordInboundSession,
record: turn.record,
onPreDispatchFailure: settleDispatchBeforeStart,
runDispatch: () =>
dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
abortSignal,
skillFilter: channelConfig?.skills,
sourceReplyDeliveryMode,
disableBlockStreaming: sourceRepliesAreToolOnly
? true
: (draftPreview.disableBlockStreamingForDraft ??
(typeof resolvedBlockStreamingEnabled === "boolean"
? !resolvedBlockStreamingEnabled
: undefined)),
onPartialReply: draftPreview.draftStream
? (payload) => draftPreview.updateFromPartial(payload.text)
: undefined,
onAssistantMessageStart: draftPreview.draftStream
? draftPreview.handleAssistantMessageBoundary
: undefined,
onReasoningEnd: draftPreview.draftStream
? draftPreview.handleAssistantMessageBoundary
: undefined,
onModelSelected,
suppressDefaultToolProgressMessages: draftPreview.previewToolProgressEnabled
? true
: undefined,
onReasoningStream: async () => {
await statusReactions.setThinking();
},
onToolStart: async (payload) => {
if (isProcessAborted(abortSignal)) {
return;
}
await statusReactions.setTool(payload.name);
draftPreview.pushToolProgress(
payload.name ? `tool: ${payload.name}` : "tool running",
);
},
onItemEvent: async (payload) => {
draftPreview.pushToolProgress(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
);
},
onPlanUpdate: async (payload) => {
if (payload.phase !== "update") {
return;
}
draftPreview.pushToolProgress(
payload.explanation ?? payload.steps?.[0] ?? "planning",
);
},
onApprovalEvent: async (payload) => {
if (payload.phase !== "requested") {
return;
}
draftPreview.pushToolProgress(
payload.command ? `approval: ${payload.command}` : "approval requested",
);
},
onCommandOutput: async (payload) => {
if (payload.phase !== "end") {
return;
}
draftPreview.pushToolProgress(
payload.name
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
: payload.title,
);
},
onPatchSummary: async (payload) => {
if (payload.phase !== "end") {
return;
}
draftPreview.pushToolProgress(payload.summary ?? payload.title ?? "patch applied");
},
onCompactionStart: async () => {
if (isProcessAborted(abortSignal)) {
return;
}
await statusReactions.setCompacting();
},
onCompactionEnd: async () => {
if (isProcessAborted(abortSignal)) {
return;
}
statusReactions.cancelPending();
await statusReactions.setThinking();
},
},
raw: ctx,
adapter: {
ingest: () => ({
id: message.id,
timestamp: message.timestamp ? Date.parse(message.timestamp) : undefined,
rawText: text,
textForAgent: ctxPayload.BodyForAgent,
textForCommands: ctxPayload.CommandBody,
raw: message,
}),
resolveTurn: () => ({
channel: "discord",
accountId: route.accountId,
routeSessionKey: persistedSessionKey,
storePath: turn.storePath,
ctxPayload,
recordInboundSession,
record: turn.record,
history: {
isGroup: isGuildMessage,
historyKey: messageChannelId,
historyMap: guildHistories,
limit: historyLimit,
},
onPreDispatchFailure: settleDispatchBeforeStart,
runDispatch: () =>
dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
abortSignal,
skillFilter: channelConfig?.skills,
sourceReplyDeliveryMode,
disableBlockStreaming: sourceRepliesAreToolOnly
? true
: (draftPreview.disableBlockStreamingForDraft ??
(typeof resolvedBlockStreamingEnabled === "boolean"
? !resolvedBlockStreamingEnabled
: undefined)),
onPartialReply: draftPreview.draftStream
? (payload) => draftPreview.updateFromPartial(payload.text)
: undefined,
onAssistantMessageStart: draftPreview.draftStream
? draftPreview.handleAssistantMessageBoundary
: undefined,
onReasoningEnd: draftPreview.draftStream
? draftPreview.handleAssistantMessageBoundary
: undefined,
onModelSelected,
suppressDefaultToolProgressMessages: draftPreview.previewToolProgressEnabled
? true
: undefined,
onReasoningStream: async () => {
await statusReactions.setThinking();
},
onToolStart: async (payload) => {
if (isProcessAborted(abortSignal)) {
return;
}
await statusReactions.setTool(payload.name);
draftPreview.pushToolProgress(
payload.name ? `tool: ${payload.name}` : "tool running",
);
},
onItemEvent: async (payload) => {
draftPreview.pushToolProgress(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
);
},
onPlanUpdate: async (payload) => {
if (payload.phase !== "update") {
return;
}
draftPreview.pushToolProgress(
payload.explanation ?? payload.steps?.[0] ?? "planning",
);
},
onApprovalEvent: async (payload) => {
if (payload.phase !== "requested") {
return;
}
draftPreview.pushToolProgress(
payload.command ? `approval: ${payload.command}` : "approval requested",
);
},
onCommandOutput: async (payload) => {
if (payload.phase !== "end") {
return;
}
draftPreview.pushToolProgress(
payload.name
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
: payload.title,
);
},
onPatchSummary: async (payload) => {
if (payload.phase !== "end") {
return;
}
draftPreview.pushToolProgress(
payload.summary ?? payload.title ?? "patch applied",
);
},
onCompactionStart: async () => {
if (isProcessAborted(abortSignal)) {
return;
}
await statusReactions.setCompacting();
},
onCompactionEnd: async () => {
if (isProcessAborted(abortSignal)) {
return;
}
statusReactions.cancelPending();
await statusReactions.setThinking();
},
},
}),
}),
},
});
if (!preparedResult.dispatched) {
return;
}
dispatchResult = preparedResult.dispatchResult;
if (isProcessAborted(abortSignal)) {
dispatchAborted = true;
@@ -646,27 +671,14 @@ export async function processDiscordMessage(
return;
}
if (!hasFinalInboundReplyDispatch(dispatchResult)) {
if (isGuildMessage) {
clearHistoryEntriesIfEnabled({
historyMap: guildHistories,
historyKey: messageChannelId,
limit: historyLimit,
});
}
const finalDispatchResult = dispatchResult;
if (!finalDispatchResult || !hasFinalInboundReplyDispatch(finalDispatchResult)) {
return;
}
if (shouldLogVerbose()) {
const finalCount = dispatchResult.counts.final;
const finalCount = finalDispatchResult.counts.final;
logVerbose(
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
);
}
if (isGuildMessage) {
clearHistoryEntriesIfEnabled({
historyMap: guildHistories,
historyKey: messageChannelId,
limit: historyLimit,
});
}
}

View File

@@ -128,4 +128,32 @@ describe("discordConfigAdapter", () => {
"123456789",
]);
});
it("keeps read-only accessors from resolving token SecretRefs", () => {
const cfg = {
secrets: {
providers: {
discord_token: {
source: "file",
path: "/tmp/openclaw-missing-discord-token",
mode: "singleValue",
},
},
},
channels: {
discord: {
token: { source: "file", provider: "discord_token", id: "value" },
allowFrom: ["1128540374256849009"],
defaultTo: "1498959610751750304",
},
},
} as OpenClawConfig;
expect(discordConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual([
"1128540374256849009",
]);
expect(discordConfigAdapter.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe(
"1498959610751750304",
);
});
});

View File

@@ -1,4 +1,5 @@
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
@@ -7,6 +8,7 @@ import { inspectDiscordAccount } from "./account-inspect.js";
import {
isDiscordAccountEnabledForRuntime,
listDiscordAccountIds,
mergeDiscordAccountConfig,
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
resolveDiscordAccountAllowFrom,
@@ -66,10 +68,13 @@ function resolveDiscordConfigAccessorAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): DiscordConfigAccessorAccount {
const account = resolveDiscordAccount(params);
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultDiscordAccountId(params.cfg),
);
const config = mergeDiscordAccountConfig(params.cfg, accountId);
return {
allowFrom: resolveDiscordAccountAllowFrom({ cfg: params.cfg, accountId: account.accountId }),
defaultTo: account.config.defaultTo,
allowFrom: resolveDiscordAccountAllowFrom({ cfg: params.cfg, accountId }),
defaultTo: config.defaultTo,
};
}

View File

@@ -0,0 +1,131 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
const createFeishuClientMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
import { registerFeishuBitableTools } from "./bitable.js";
type MockRecord = {
record_id?: string;
fields?: Record<string, unknown>;
};
function createConfig(): OpenClawPluginApi["config"] {
return {
channels: {
feishu: {
enabled: true,
accounts: {
default: {
appId: "cli_default",
appSecret: "secret_default", // pragma: allowlist secret
},
},
},
},
} as OpenClawPluginApi["config"];
}
function createBitableClient(records: MockRecord[]) {
const batchDelete = vi.fn(async () => ({ code: 0 }));
const client = {
bitable: {
app: {
create: vi.fn(async () => ({
code: 0,
data: {
app: {
app_token: "app_token",
name: "Project Tracker",
url: "https://example.feishu.cn/base/app_token",
},
},
})),
},
appTable: {
list: vi.fn(async () => ({
code: 0,
data: { items: [{ table_id: "tbl_main", name: "Table 1" }] },
})),
},
appTableField: {
list: vi.fn(async () => ({ code: 0, data: { items: [] } })),
update: vi.fn(async () => ({ code: 0 })),
delete: vi.fn(async () => ({ code: 0 })),
},
appTableRecord: {
list: vi.fn(async () => ({ code: 0, data: { items: records } })),
batchDelete,
delete: vi.fn(async () => ({ code: 0 })),
},
},
} as unknown as Lark.Client;
return { batchDelete, client };
}
describe("feishu bitable create app cleanup", () => {
beforeEach(() => {
createFeishuClientMock.mockReset();
});
it("deletes placeholder rows whose fields contain only default empty values", async () => {
const { batchDelete, client } = createBitableClient([
{ record_id: "rec_missing_fields" },
{ record_id: "rec_empty_fields", fields: {} },
{
record_id: "rec_empty_defaults",
fields: {
Name: "",
Status: [],
Attachments: [],
Started: null,
EmptyObject: {},
},
},
{
record_id: "rec_empty_rich_text",
fields: { Notes: [{ type: "text", text: "" }] },
},
{
record_id: "rec_empty_nested",
fields: { Notes: { value: "", segments: [{ type: "text", text: "" }] } },
},
{ record_id: "rec_text", fields: { Name: "Milestone" } },
{ record_id: "rec_number", fields: { Estimate: 0 } },
{ record_id: "rec_boolean", fields: { Done: false } },
{ record_id: "rec_link", fields: { Link: { text: "", link: "https://example.com" } } },
{ record_id: "rec_attachment", fields: { Attachments: [{ file_token: "boxcn_token" }] } },
{ record_id: "rec_user", fields: { Assignee: [{ id: "ou_1", name: "" }] } },
{ record_id: "rec_location", fields: { Location: { name: "", location: "116,39" } } },
]);
createFeishuClientMock.mockReturnValue(client);
const { api, resolveTool } = createToolFactoryHarness(createConfig());
registerFeishuBitableTools(api);
const result = await resolveTool("feishu_bitable_create_app").execute("call", {
name: "Project Tracker",
});
expect(result.details.cleaned_placeholder_rows).toBe(5);
expect(batchDelete).toHaveBeenCalledWith({
path: { app_token: "app_token", table_id: "tbl_main" },
data: {
records: [
"rec_missing_fields",
"rec_empty_fields",
"rec_empty_defaults",
"rec_empty_rich_text",
"rec_empty_nested",
],
},
});
});
});

View File

@@ -245,6 +245,35 @@ type CleanupLogger = {
/** Default field types created for new Bitable tables (to be cleaned up) */
const DEFAULT_CLEANUP_FIELD_TYPES = new Set([3, 5, 17]); // SingleSelect, DateTime, Attachment
function isDefaultEmptyBitableFieldValue(value: unknown): boolean {
if (value === undefined || value === null || value === "") {
return true;
}
if (Array.isArray(value)) {
return value.every(isDefaultEmptyBitableFieldValue);
}
if (typeof value === "object") {
const record = value as Record<string, unknown>;
const keys = Object.keys(record);
if (keys.length === 0) {
return true;
}
if ("text" in record && keys.every((key) => key === "text" || key === "type")) {
return record.text === undefined || record.text === null || record.text === "";
}
return Object.values(record).every(isDefaultEmptyBitableFieldValue);
}
return false;
}
function isPlaceholderBitableRecord(fields: unknown): boolean {
if (!fields || typeof fields !== "object" || Array.isArray(fields)) {
return true;
}
const values = Object.values(fields);
return values.every(isDefaultEmptyBitableFieldValue);
}
/** Clean up default placeholder rows and fields in a newly created Bitable table */
async function cleanupNewBitable(
client: Lark.Client,
@@ -315,7 +344,7 @@ async function cleanupNewBitable(
if (recordsRes.code === 0 && recordsRes.data?.items) {
const emptyRecordIds = recordsRes.data.items
.filter((r) => !r.fields || Object.keys(r.fields).length === 0)
.filter((r) => isPlaceholderBitableRecord(r.fields))
.map((r) => r.record_id)
.filter((id): id is string => Boolean(id));

View File

@@ -111,6 +111,39 @@ describe("broadcast dispatch", () => {
saveMediaBuffer: mockSaveMediaBuffer,
},
turn: {
run: vi.fn(async (params: Parameters<PluginRuntime["channel"]["turn"]["run"]>[0]) => {
const input = await params.adapter.ingest(params.raw);
if (!input) {
return {
admission: { kind: "drop" as const, reason: "ingest-null" },
dispatched: false,
};
}
const eventClass = {
kind: "message" as const,
canStartAgentTurn: true,
};
const turn = await params.adapter.resolveTurn(input, eventClass, {});
if (!("runDispatch" in turn)) {
throw new Error("feishu broadcast test runtime only supports prepared turns");
}
await turn.recordInboundSession({
storePath: turn.storePath,
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
ctx: turn.ctxPayload,
groupResolution: turn.record?.groupResolution,
createIfMissing: turn.record?.createIfMissing,
updateLastRoute: turn.record?.updateLastRoute,
onRecordError: turn.record?.onRecordError ?? (() => undefined),
});
return {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey,
dispatchResult: await turn.runDispatch(),
};
}),
runPrepared: vi.fn(
async (turn: Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0]) => {
await turn.recordInboundSession({

View File

@@ -198,6 +198,16 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
buildPairingReply: vi.fn(),
},
turn: {
run: vi.fn(async (params) => {
const input = await params.adapter.ingest(params.raw);
const turn = await params.adapter.resolveTurn(input, {
kind: "message",
canStartAgentTurn: true,
});
return {
dispatchResult: await turn.runDispatch(),
};
}),
runPrepared: vi.fn(async (params) => ({
dispatchResult: await params.runDispatch(),
})),
@@ -2989,4 +2999,42 @@ describe("handleFeishuMessage command authorization", () => {
await Promise.all([dispatchMessage({ cfg, event }), dispatchMessage({ cfg, event })]);
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("skips empty-text messages with no media to prevent blank user turns in session (#74634)", async () => {
// Feishu can deliver { "text": "" } events (empty-text or media-stripped
// messages). Writing blank user content to the session causes downstream
// LLM providers such as MiniMax to reject requests with "messages must not
// be empty". The handler should drop such events before queuing a reply.
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-empty-text-sender",
},
},
message: {
message_id: "msg-empty-text-74634",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
// Feishu encodes empty text as {"text":""}
content: JSON.stringify({ text: "" }),
},
};
await dispatchMessage({ cfg, event });
// No reply should be dispatched: empty message is silently skipped
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
});

View File

@@ -859,6 +859,19 @@ export async function handleFeishuMessage(params: {
log,
accountId: account.accountId,
});
// Skip messages with no text content and no media attachments. Feishu can
// deliver empty-text events (e.g. `{"text":""}`) when a user sends a blank
// message or when media parsing produces an empty string. Writing a blank
// user turn to the session causes downstream LLM providers (e.g. MiniMax)
// to reject the request with "messages must not be empty" errors. Logging
// the skip avoids silent loss without polluting the agent session.
if (!ctx.content.trim() && mediaList.length === 0) {
log(
`feishu[${account.accountId}]: skipping empty message (no text, no media) from ${ctx.senderOpenId}`,
);
return;
}
const mediaPayload = buildAgentMediaPayload(mediaList);
const audioTranscript = await resolveFeishuAudioPreflightTranscript({
cfg: effectiveCfg,
@@ -1312,31 +1325,46 @@ export async function handleFeishuMessage(params: {
log(
`feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
);
await core.channel.turn.runPrepared({
await core.channel.turn.run({
channel: "feishu",
accountId: route.accountId,
routeSessionKey: agentSessionKey,
storePath: agentStorePath,
ctxPayload: agentCtx,
recordInboundSession: core.channel.session.recordInboundSession,
record: agentRecord,
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
raw: ctx,
adapter: {
ingest: () => ({
id: ctx.messageId,
timestamp: messageCreateTimeMs,
rawText: ctx.content,
textForAgent: agentCtx.BodyForAgent,
textForCommands: agentCtx.CommandBody,
raw: ctx,
}),
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: agentCtx,
cfg,
resolveTurn: () => ({
channel: "feishu",
accountId: route.accountId,
routeSessionKey: agentSessionKey,
storePath: agentStorePath,
ctxPayload: agentCtx,
recordInboundSession: core.channel.session.recordInboundSession,
record: agentRecord,
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher,
replyOptions,
onSettled: () => markDispatchIdle(),
}),
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: agentCtx,
cfg,
dispatcher,
replyOptions,
}),
}),
}),
},
});
} else {
// Observer agent: no-op dispatcher (session entry + inference, no Feishu reply).
@@ -1356,24 +1384,39 @@ export async function handleFeishuMessage(params: {
log(
`feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`,
);
await core.channel.turn.runPrepared({
await core.channel.turn.run({
channel: "feishu",
accountId: route.accountId,
routeSessionKey: agentSessionKey,
storePath: agentStorePath,
ctxPayload: agentCtx,
recordInboundSession: core.channel.session.recordInboundSession,
record: agentRecord,
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher: noopDispatcher,
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: agentCtx,
cfg,
raw: ctx,
adapter: {
ingest: () => ({
id: ctx.messageId,
timestamp: messageCreateTimeMs,
rawText: ctx.content,
textForAgent: agentCtx.BodyForAgent,
textForCommands: agentCtx.CommandBody,
raw: ctx,
}),
resolveTurn: () => ({
channel: "feishu",
accountId: route.accountId,
routeSessionKey: agentSessionKey,
storePath: agentStorePath,
ctxPayload: agentCtx,
recordInboundSession: core.channel.session.recordInboundSession,
record: agentRecord,
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher: noopDispatcher,
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: agentCtx,
cfg,
dispatcher: noopDispatcher,
}),
}),
}),
},
});
}
};
@@ -1445,49 +1488,66 @@ export async function handleFeishuMessage(params: {
});
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
const { dispatchResult } = await core.channel.turn.runPrepared({
const turnResult = await core.channel.turn.run({
channel: "feishu",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
onRecordError: (err) => {
log(
`feishu[${account.accountId}]: failed to record inbound session ${route.sessionKey}: ${String(err)}`,
);
},
},
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
raw: ctx,
adapter: {
ingest: () => ({
id: ctx.messageId,
timestamp: messageCreateTimeMs,
rawText: ctx.content,
textForAgent: ctxPayload.BodyForAgent,
textForCommands: ctxPayload.CommandBody,
raw: ctx,
}),
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
resolveTurn: () => ({
channel: "feishu",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
onRecordError: (err) => {
log(
`feishu[${account.accountId}]: failed to record inbound session ${route.sessionKey}: ${String(err)}`,
);
},
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
history: {
isGroup,
historyKey,
historyMap: chatHistories,
limit: historyLimit,
},
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher,
replyOptions,
onSettled: () => markDispatchIdle(),
}),
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions,
}),
}),
}),
},
});
const { queuedFinal, counts } = dispatchResult;
if (isGroup && historyKey && chatHistories) {
clearHistoryEntriesIfEnabled({
historyMap: chatHistories,
historyKey,
limit: historyLimit,
});
if (!turnResult.dispatched) {
return;
}
const { dispatchResult } = turnResult;
const { queuedFinal, counts } = dispatchResult;
log(
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,

View File

@@ -134,6 +134,26 @@ function createTestRuntime(overrides?: {
recordInboundSession,
},
turn: {
run: vi.fn(async (params: Parameters<PluginRuntime["channel"]["turn"]["run"]>[0]) => {
const input = await params.adapter.ingest(params.raw);
if (!input) {
return {
admission: { kind: "drop" as const, reason: "ingest-null" },
dispatched: false,
};
}
const eventClass = {
kind: "message" as const,
canStartAgentTurn: true,
};
const turn = await params.adapter.resolveTurn(input, eventClass, {});
if (!("runDispatch" in turn)) {
throw new Error("feishu comment test runtime only supports prepared turns");
}
return await runPrepared(
turn as Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0],
);
}) as unknown as PluginRuntime["channel"]["turn"]["run"],
runPrepared: runPrepared as unknown as PluginRuntime["channel"]["turn"]["runPrepared"],
},
pairing: {

View File

@@ -241,42 +241,58 @@ export async function handleFeishuCommentEvent(
`feishu[${account.accountId}]: dispatching drive comment to agent ` +
`(session=${commentSessionKey} comment=${turn.commentId} type=${turn.noticeType})`,
);
const { dispatchResult } = await core.channel.turn.runPrepared({
const turnResult = await core.channel.turn.run({
channel: "feishu",
accountId: route.accountId,
routeSessionKey: commentSessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
onRecordError: (err) => {
error(
`feishu[${account.accountId}]: failed to record comment inbound session ${commentSessionKey}: ${String(err)}`,
);
},
},
onPreDispatchFailure: async () => {
dispatchSettledBeforeStart = true;
await core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => {
markRunComplete();
markDispatchIdle();
raw: turn,
adapter: {
ingest: () => ({
id: turn.messageId,
timestamp: parseTimestampMs(turn.timestamp),
rawText: ctxPayload.RawBody ?? "",
textForAgent: ctxPayload.BodyForAgent,
textForCommands: ctxPayload.CommandBody,
raw: turn,
}),
resolveTurn: () => ({
channel: "feishu",
accountId: route.accountId,
routeSessionKey: commentSessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
onRecordError: (err) => {
error(
`feishu[${account.accountId}]: failed to record comment inbound session ${commentSessionKey}: ${String(err)}`,
);
},
},
});
},
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg: effectiveCfg,
onPreDispatchFailure: async () => {
dispatchSettledBeforeStart = true;
await core.channel.reply.settleReplyDispatcher({
dispatcher,
replyOptions,
onSettled: () => {
markRunComplete();
markDispatchIdle();
},
});
},
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg: effectiveCfg,
dispatcher,
replyOptions,
}),
}),
}),
},
});
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
const queuedFinal = dispatchResult?.queuedFinal ?? false;
const counts = dispatchResult?.counts ?? { tool: 0, block: 0, final: 0 };
log(

View File

@@ -0,0 +1,70 @@
import {
definePluginEntry,
type OpenClawPluginNodeHostCommand,
} from "openclaw/plugin-sdk/plugin-entry";
import { handleDirFetch } from "./src/node-host/dir-fetch.js";
import { handleDirList } from "./src/node-host/dir-list.js";
import { handleFileFetch } from "./src/node-host/file-fetch.js";
import { handleFileWrite } from "./src/node-host/file-write.js";
import { createFileTransferNodeInvokePolicy } from "./src/shared/node-invoke-policy.js";
import { createDirFetchTool } from "./src/tools/dir-fetch-tool.js";
import { createDirListTool } from "./src/tools/dir-list-tool.js";
import { createFileFetchTool } from "./src/tools/file-fetch-tool.js";
import { createFileWriteTool } from "./src/tools/file-write-tool.js";
const fileTransferNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
{
command: "file.fetch",
cap: "file",
dangerous: true,
handle: async (paramsJSON) => {
const params = paramsJSON ? JSON.parse(paramsJSON) : {};
const result = await handleFileFetch(params);
return JSON.stringify(result);
},
},
{
command: "dir.list",
cap: "file",
dangerous: true,
handle: async (paramsJSON) => {
const params = paramsJSON ? JSON.parse(paramsJSON) : {};
const result = await handleDirList(params);
return JSON.stringify(result);
},
},
{
command: "dir.fetch",
cap: "file",
dangerous: true,
handle: async (paramsJSON) => {
const params = paramsJSON ? JSON.parse(paramsJSON) : {};
const result = await handleDirFetch(params);
return JSON.stringify(result);
},
},
{
command: "file.write",
cap: "file",
dangerous: true,
handle: async (paramsJSON) => {
const params = paramsJSON ? JSON.parse(paramsJSON) : {};
const result = await handleFileWrite(params);
return JSON.stringify(result);
},
},
];
export default definePluginEntry({
id: "file-transfer",
name: "File Transfer",
description: "Fetch, list, and write files on paired nodes via dedicated node commands.",
nodeHostCommands: fileTransferNodeHostCommands,
register(api) {
api.registerNodeInvokePolicy(createFileTransferNodeInvokePolicy());
api.registerTool(createFileFetchTool());
api.registerTool(createDirListTool());
api.registerTool(createDirFetchTool());
api.registerTool(createFileWriteTool());
},
});

View File

@@ -0,0 +1,50 @@
{
"id": "file-transfer",
"activation": {
"onStartup": true
},
"enabledByDefault": true,
"name": "File Transfer",
"description": "Fetch, list, and write files on paired nodes via dedicated node commands. Bypasses bash stdout truncation by using base64 over node.invoke for binaries up to 16 MB.",
"contracts": {
"tools": ["file_fetch", "dir_list", "dir_fetch", "file_write"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"nodes": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"properties": {
"ask": {
"type": "string",
"enum": ["off", "on-miss", "always"]
},
"allowReadPaths": {
"type": "array",
"items": { "type": "string" }
},
"allowWritePaths": {
"type": "array",
"items": { "type": "string" }
},
"denyPaths": {
"type": "array",
"items": { "type": "string" }
},
"maxBytes": {
"type": "number"
},
"followSymlinks": {
"type": "boolean",
"default": false
}
}
}
}
}
}
}

View File

@@ -0,0 +1,21 @@
{
"name": "@openclaw/file-transfer",
"version": "2026.4.27",
"description": "OpenClaw file transfer plugin (file_fetch, dir_list, dir_fetch, file_write)",
"type": "module",
"dependencies": {
"minimatch": "10.2.4",
"typebox": "1.1.34"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"bundle": {
"stageRuntimeDependencies": false
}
}
}

View File

@@ -0,0 +1,135 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { handleDirFetch } from "./dir-fetch.js";
let tmpRoot: string;
beforeEach(async () => {
// realpath: see file-fetch.test.ts for the macOS symlinked-tmpdir reason.
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "dir-fetch-test-")));
});
afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true });
});
// dir-fetch shells out to /usr/bin/tar. Skip the body of these tests on
// platforms without it (Windows CI). They still register, just no-op.
const HAS_TAR = process.platform !== "win32";
describe("handleDirFetch — input validation", () => {
it("rejects empty / non-string path", async () => {
expect(await handleDirFetch({ path: "" })).toMatchObject({
ok: false,
code: "INVALID_PATH",
});
});
it("rejects relative paths", async () => {
expect(await handleDirFetch({ path: "relative" })).toMatchObject({
ok: false,
code: "INVALID_PATH",
});
});
it("rejects paths with NUL bytes", async () => {
expect(await handleDirFetch({ path: "/tmp/foo\0bar" })).toMatchObject({
ok: false,
code: "INVALID_PATH",
});
});
});
describe("handleDirFetch — fs errors", () => {
it.runIf(HAS_TAR)("returns NOT_FOUND for a missing directory", async () => {
const r = await handleDirFetch({ path: path.join(tmpRoot, "missing") });
expect(r).toMatchObject({ ok: false, code: "NOT_FOUND" });
});
it.runIf(HAS_TAR)("returns IS_FILE when path resolves to a file", async () => {
const f = path.join(tmpRoot, "f.txt");
await fs.writeFile(f, "x");
expect(await handleDirFetch({ path: f })).toMatchObject({
ok: false,
code: "IS_FILE",
});
});
});
describe("handleDirFetch — happy path", () => {
it("preflights directory entries without creating a tarball", async () => {
await fs.writeFile(path.join(tmpRoot, "a.txt"), "alpha\n");
await fs.mkdir(path.join(tmpRoot, ".ssh"));
await fs.writeFile(path.join(tmpRoot, ".ssh", "id_rsa"), "secret\n");
await fs.mkdir(path.join(tmpRoot, "sub"));
await fs.writeFile(path.join(tmpRoot, "sub", "b.txt"), "beta\n");
const r = await handleDirFetch({ path: tmpRoot, preflightOnly: true });
if (!r.ok) {
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
}
expect(r).toMatchObject({
path: tmpRoot,
tarBase64: "",
tarBytes: 0,
sha256: "",
preflightOnly: true,
});
expect(r.entries).toEqual([".ssh", ".ssh/id_rsa", "a.txt", "sub", "sub/b.txt"]);
expect(r.fileCount).toBe(r.entries?.length);
});
it.runIf(HAS_TAR)("returns a gzipped tar with byte count and sha256", async () => {
await fs.writeFile(path.join(tmpRoot, "a.txt"), "alpha\n");
await fs.writeFile(path.join(tmpRoot, "b.txt"), "beta\n");
await fs.mkdir(path.join(tmpRoot, "sub"));
await fs.writeFile(path.join(tmpRoot, "sub", "c.txt"), "gamma\n");
const r = await handleDirFetch({ path: tmpRoot });
if (!r.ok) {
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
}
expect(r.tarBytes).toBeGreaterThan(0);
expect(r.tarBase64.length).toBeGreaterThan(0);
const buf = Buffer.from(r.tarBase64, "base64");
expect(buf.byteLength).toBe(r.tarBytes);
const expectedSha = crypto.createHash("sha256").update(buf).digest("hex");
expect(r.sha256).toBe(expectedSha);
// gzip magic bytes
expect(buf[0]).toBe(0x1f);
expect(buf[1]).toBe(0x8b);
// file count covers the regular files we created (3); BSD tar may also
// list directory entries, so be generous.
expect(r.fileCount).toBeGreaterThanOrEqual(3);
expect(r.entries).toEqual(expect.arrayContaining(["a.txt", "b.txt", "sub", "sub/c.txt"]));
expect(r.fileCount).toBe(r.entries?.length);
});
});
describe("handleDirFetch — size cap", () => {
it.runIf(HAS_TAR)(
"returns TREE_TOO_LARGE when content exceeds the cap mid-stream",
async () => {
// Write enough random content to exceed a small maxBytes. Random bytes
// don't compress, so gzip output is roughly the same size as input.
const big = crypto.randomBytes(512 * 1024);
await fs.writeFile(path.join(tmpRoot, "big1.bin"), big);
await fs.writeFile(path.join(tmpRoot, "big2.bin"), big);
await fs.writeFile(path.join(tmpRoot, "big3.bin"), big);
// 64KB cap should trip either the du preflight or the streaming SIGTERM.
const r = await handleDirFetch({ path: tmpRoot, maxBytes: 64 * 1024 });
expect(r).toMatchObject({ ok: false, code: "TREE_TOO_LARGE" });
},
30_000,
);
});

View File

@@ -0,0 +1,381 @@
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
export const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
export const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
export type DirFetchParams = {
path?: unknown;
maxBytes?: unknown;
includeDotfiles?: unknown;
followSymlinks?: unknown;
preflightOnly?: unknown;
};
export type DirFetchOk = {
ok: true;
path: string;
tarBase64: string;
tarBytes: number;
sha256: string;
fileCount: number;
entries?: string[];
preflightOnly?: boolean;
};
export type DirFetchErrCode =
| "INVALID_PATH"
| "NOT_FOUND"
| "IS_FILE"
| "TREE_TOO_LARGE"
| "SYMLINK_REDIRECT"
| "READ_ERROR";
export type DirFetchErr = {
ok: false;
code: DirFetchErrCode;
message: string;
canonicalPath?: string;
};
export type DirFetchResult = DirFetchOk | DirFetchErr;
function clampMaxBytes(input: unknown): number {
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
return DIR_FETCH_DEFAULT_MAX_BYTES;
}
return Math.min(Math.floor(input), DIR_FETCH_HARD_MAX_BYTES);
}
function classifyFsError(err: unknown): DirFetchErrCode {
const code = (err as { code?: string } | null)?.code;
if (code === "ENOENT") {
return "NOT_FOUND";
}
return "READ_ERROR";
}
async function preflightDu(dirPath: string, maxBytes: number): Promise<boolean> {
// du -sk gives size in 1KB blocks (512-byte blocks on macOS with -k)
// We use maxBytes * 4 as the rough heuristic ceiling (generous, gzip compresses)
const heuristicKb = Math.ceil((maxBytes * 4) / 1024);
return new Promise((resolve) => {
const du = spawn("du", ["-sk", dirPath], { stdio: ["ignore", "pipe", "ignore"] });
let output = "";
du.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
du.on("close", (code) => {
if (code !== 0) {
// du failed; be permissive and let tar catch the overflow
resolve(true);
return;
}
const match = /^(\d+)/.exec(output.trim());
if (!match) {
resolve(true);
return;
}
const sizeKb = Number.parseInt(match[1], 10);
resolve(sizeKb <= heuristicKb);
});
du.on("error", () => {
// du not available; skip preflight
resolve(true);
});
});
}
async function listTarEntries(tarBuffer: Buffer): Promise<string[]> {
// Async spawn so a slow `tar -tzf` doesn't park the node-host event
// loop for up to 10s. Other in-flight requests continue to be served.
return new Promise<string[]>((resolve) => {
const child = spawn("tar", ["-tzf", "-"], { stdio: ["pipe", "pipe", "ignore"] });
let stdoutBuf = "";
let aborted = false;
const watchdog = setTimeout(() => {
aborted = true;
try {
child.kill("SIGKILL");
} catch {
/* gone */
}
resolve([]);
}, 10_000);
child.stdout.on("data", (chunk: Buffer) => {
stdoutBuf += chunk.toString();
// Bound buffer growth — pathological archives shouldn't OOM us.
if (stdoutBuf.length > 32 * 1024 * 1024) {
aborted = true;
try {
child.kill("SIGKILL");
} catch {
/* gone */
}
clearTimeout(watchdog);
resolve([]);
}
});
child.on("close", (code) => {
clearTimeout(watchdog);
if (aborted) {
return;
}
if (code !== 0) {
resolve([]);
return;
}
const lines = stdoutBuf
.split("\n")
.map((line) => line.replace(/\\/gu, "/").replace(/^\.\//u, "").replace(/\/$/u, ""))
.filter((line) => line.length > 0);
resolve(lines);
});
child.on("error", () => {
clearTimeout(watchdog);
if (!aborted) {
resolve([]);
}
});
child.stdin.end(tarBuffer);
});
}
async function listTreeEntries(root: string, maxEntries: number): Promise<string[] | "TOO_MANY"> {
const results: string[] = [];
async function visit(dir: string): Promise<boolean> {
const entries = await fs.readdir(dir, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const abs = path.join(dir, entry.name);
const rel = path.relative(root, abs).replace(/\\/gu, "/");
results.push(rel);
if (results.length > maxEntries) {
return false;
}
if (entry.isDirectory()) {
const ok = await visit(abs);
if (!ok) {
return false;
}
}
}
return true;
}
return (await visit(root)) ? results : "TOO_MANY";
}
export async function handleDirFetch(params: DirFetchParams): Promise<DirFetchResult> {
const requestedPath = params.path;
if (typeof requestedPath !== "string" || requestedPath.length === 0) {
return { ok: false, code: "INVALID_PATH", message: "path required" };
}
if (requestedPath.includes("\0")) {
return { ok: false, code: "INVALID_PATH", message: "path contains NUL byte" };
}
if (!path.isAbsolute(requestedPath)) {
return { ok: false, code: "INVALID_PATH", message: "path must be absolute" };
}
const maxBytes = clampMaxBytes(params.maxBytes);
const includeDotfiles = params.includeDotfiles === true;
const followSymlinks = params.followSymlinks === true;
const preflightOnly = params.preflightOnly === true;
let canonical: string;
try {
canonical = await fs.realpath(requestedPath);
} catch (err) {
const code = classifyFsError(err);
return {
ok: false,
code,
message: code === "NOT_FOUND" ? "directory not found" : `realpath failed: ${String(err)}`,
};
}
if (!followSymlinks && canonical !== requestedPath) {
return {
ok: false,
code: "SYMLINK_REDIRECT",
message: `path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowReadPaths to the canonical path)`,
canonicalPath: canonical,
};
}
let stats: Awaited<ReturnType<typeof fs.stat>>;
try {
stats = await fs.stat(canonical);
} catch (err) {
const code = classifyFsError(err);
return { ok: false, code, message: `stat failed: ${String(err)}`, canonicalPath: canonical };
}
if (!stats.isDirectory()) {
return {
ok: false,
code: "IS_FILE",
message: "path is not a directory",
canonicalPath: canonical,
};
}
if (preflightOnly) {
try {
const entries = await listTreeEntries(canonical, 5000);
if (entries === "TOO_MANY") {
return {
ok: false,
code: "TREE_TOO_LARGE",
message: "directory tree exceeds 5000 entries during preflight",
canonicalPath: canonical,
};
}
return {
ok: true,
path: canonical,
tarBase64: "",
tarBytes: 0,
sha256: "",
fileCount: entries.length,
entries,
preflightOnly: true,
};
} catch (err) {
const code = classifyFsError(err);
return {
ok: false,
code,
message: `preflight readdir failed: ${String(err)}`,
canonicalPath: canonical,
};
}
}
// Preflight size check using du
const withinBudget = await preflightDu(canonical, maxBytes);
if (!withinBudget) {
return {
ok: false,
code: "TREE_TOO_LARGE",
message: `directory tree exceeds estimated size limit (${maxBytes} bytes raw)`,
canonicalPath: canonical,
};
}
// Build tar args. Shell out to /usr/bin/tar for portability.
// -cz: create + gzip
// -C <dir>: change to directory so paths in archive are relative
// .: include everything from that directory
// v1: includeDotfiles is accepted in the API but not enforced. BSD tar's
// --exclude pattern matching is unreliable for dotfiles (every plausible
// pattern except "*/.*" collapses the archive on macOS). Reliable filtering
// requires a `find ! -name '.*' | tar -T -` pipeline; deferred to v2.
// For now we always archive everything in the directory.
void includeDotfiles;
const tarArgs: string[] = ["-czf", "-", "-C", canonical, "."];
// Capture tar output with a hard byte cap and a wall-clock timeout.
// SIGTERM if the byte cap is exceeded; SIGKILL if the timeout fires
// (covers tar hanging on a slow filesystem or symlink loop).
const TAR_HARD_TIMEOUT_MS = 60_000;
const tarBuffer = await new Promise<Buffer | "TOO_LARGE" | "TIMEOUT" | "ERROR">((resolve) => {
const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
const child = spawn(tarBin, tarArgs, {
stdio: ["ignore", "pipe", "pipe"],
});
const chunks: Buffer[] = [];
let totalBytes = 0;
let aborted = false;
const watchdog = setTimeout(() => {
if (aborted) {
return;
}
aborted = true;
try {
child.kill("SIGKILL");
} catch {
/* already gone */
}
resolve("TIMEOUT");
}, TAR_HARD_TIMEOUT_MS);
child.stdout.on("data", (chunk: Buffer) => {
if (aborted) {
return;
}
totalBytes += chunk.byteLength;
if (totalBytes > maxBytes) {
aborted = true;
clearTimeout(watchdog);
child.kill("SIGTERM");
resolve("TOO_LARGE");
return;
}
chunks.push(chunk);
});
child.on("close", (code) => {
clearTimeout(watchdog);
if (aborted) {
return;
}
if (code !== 0) {
resolve("ERROR");
return;
}
resolve(Buffer.concat(chunks));
});
child.on("error", () => {
clearTimeout(watchdog);
if (!aborted) {
resolve("ERROR");
}
});
});
if (tarBuffer === "TOO_LARGE") {
return {
ok: false,
code: "TREE_TOO_LARGE",
message: `tarball exceeded ${maxBytes} byte limit mid-stream`,
canonicalPath: canonical,
};
}
if (tarBuffer === "TIMEOUT") {
return {
ok: false,
code: "READ_ERROR",
message: "tar command exceeded 60s wall-clock timeout (slow filesystem or symlink loop?)",
canonicalPath: canonical,
};
}
if (tarBuffer === "ERROR") {
return {
ok: false,
code: "READ_ERROR",
message: "tar command failed",
canonicalPath: canonical,
};
}
const sha256 = crypto.createHash("sha256").update(tarBuffer).digest("hex");
const tarBase64 = tarBuffer.toString("base64");
const tarBytes = tarBuffer.byteLength;
const entries = await listTarEntries(tarBuffer);
return {
ok: true,
path: canonical,
tarBase64,
tarBytes,
sha256,
fileCount: entries.length,
entries,
};
}

View File

@@ -0,0 +1,143 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
DIR_LIST_DEFAULT_MAX_ENTRIES,
DIR_LIST_HARD_MAX_ENTRIES,
handleDirList,
} from "./dir-list.js";
let tmpRoot: string;
beforeEach(async () => {
// realpath: see file-fetch.test.ts for the macOS symlinked-tmpdir reason.
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "dir-list-test-")));
});
afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true });
});
describe("handleDirList — input validation", () => {
it("rejects empty / non-string path", async () => {
expect(await handleDirList({ path: "" })).toMatchObject({ ok: false, code: "INVALID_PATH" });
expect(await handleDirList({ path: undefined })).toMatchObject({
ok: false,
code: "INVALID_PATH",
});
});
it("rejects relative paths", async () => {
expect(await handleDirList({ path: "relative" })).toMatchObject({
ok: false,
code: "INVALID_PATH",
});
});
it("rejects paths with NUL bytes", async () => {
expect(await handleDirList({ path: "/tmp/foo\0bar" })).toMatchObject({
ok: false,
code: "INVALID_PATH",
});
});
});
describe("handleDirList — fs errors", () => {
it("returns NOT_FOUND for a missing directory", async () => {
expect(await handleDirList({ path: path.join(tmpRoot, "does-not-exist") })).toMatchObject({
ok: false,
code: "NOT_FOUND",
});
});
it("returns IS_FILE when path resolves to a regular file", async () => {
const f = path.join(tmpRoot, "f.txt");
await fs.writeFile(f, "x");
expect(await handleDirList({ path: f })).toMatchObject({ ok: false, code: "IS_FILE" });
});
});
describe("handleDirList — happy path", () => {
it("lists files and subdirs with metadata, sorted by name", async () => {
await fs.writeFile(path.join(tmpRoot, "z.txt"), "Z");
await fs.writeFile(path.join(tmpRoot, "a.png"), "PNG-bytes");
await fs.mkdir(path.join(tmpRoot, "subdir"));
const r = await handleDirList({ path: tmpRoot });
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.entries.map((e) => e.name)).toEqual(["a.png", "subdir", "z.txt"]);
const a = r.entries.find((e) => e.name === "a.png")!;
expect(a.isDir).toBe(false);
expect(a.size).toBeGreaterThan(0);
expect(a.mimeType).toBe("image/png");
const sub = r.entries.find((e) => e.name === "subdir")!;
expect(sub.isDir).toBe(true);
expect(sub.size).toBe(0);
expect(sub.mimeType).toBe("inode/directory");
expect(r.truncated).toBe(false);
expect(r.nextPageToken).toBeUndefined();
});
it("includes dotfiles in the listing", async () => {
await fs.writeFile(path.join(tmpRoot, ".hidden"), "x");
await fs.writeFile(path.join(tmpRoot, "visible"), "x");
const r = await handleDirList({ path: tmpRoot });
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.entries.map((e) => e.name)).toEqual([".hidden", "visible"]);
});
it("paginates via pageToken (offset-based)", async () => {
for (let i = 0; i < 7; i++) {
// zero-pad so localeCompare-stable sort matches creation order
await fs.writeFile(path.join(tmpRoot, `f-${i}.txt`), "x");
}
const page1 = await handleDirList({ path: tmpRoot, maxEntries: 3 });
if (!page1.ok) {
throw new Error("page1");
}
expect(page1.entries.map((e) => e.name)).toEqual(["f-0.txt", "f-1.txt", "f-2.txt"]);
expect(page1.truncated).toBe(true);
expect(page1.nextPageToken).toBe("3");
const page2 = await handleDirList({
path: tmpRoot,
maxEntries: 3,
pageToken: page1.nextPageToken,
});
if (!page2.ok) {
throw new Error("page2");
}
expect(page2.entries.map((e) => e.name)).toEqual(["f-3.txt", "f-4.txt", "f-5.txt"]);
expect(page2.truncated).toBe(true);
const page3 = await handleDirList({
path: tmpRoot,
maxEntries: 3,
pageToken: page2.nextPageToken,
});
if (!page3.ok) {
throw new Error("page3");
}
expect(page3.entries.map((e) => e.name)).toEqual(["f-6.txt"]);
expect(page3.truncated).toBe(false);
expect(page3.nextPageToken).toBeUndefined();
});
});
describe("handleDirList — limits", () => {
it("clamps maxEntries to the hard ceiling and uses the default for invalid values", () => {
expect(DIR_LIST_DEFAULT_MAX_ENTRIES).toBe(200);
expect(DIR_LIST_HARD_MAX_ENTRIES).toBe(5000);
expect(DIR_LIST_DEFAULT_MAX_ENTRIES).toBeLessThan(DIR_LIST_HARD_MAX_ENTRIES);
});
});

View File

@@ -0,0 +1,179 @@
import fs from "node:fs/promises";
import path from "node:path";
import { mimeFromExtension } from "../shared/mime.js";
export const DIR_LIST_DEFAULT_MAX_ENTRIES = 200;
export const DIR_LIST_HARD_MAX_ENTRIES = 5000;
export type DirListParams = {
path?: unknown;
pageToken?: unknown;
maxEntries?: unknown;
followSymlinks?: unknown;
};
export type DirListEntry = {
name: string;
path: string;
size: number;
mimeType: string;
isDir: boolean;
mtime: number;
};
export type DirListOk = {
ok: true;
path: string;
entries: DirListEntry[];
nextPageToken?: string;
truncated: boolean;
};
export type DirListErrCode =
| "INVALID_PATH"
| "NOT_FOUND"
| "PERMISSION_DENIED"
| "IS_FILE"
| "SYMLINK_REDIRECT"
| "READ_ERROR";
export type DirListErr = {
ok: false;
code: DirListErrCode;
message: string;
canonicalPath?: string;
};
export type DirListResult = DirListOk | DirListErr;
function clampMaxEntries(input: unknown): number {
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
return DIR_LIST_DEFAULT_MAX_ENTRIES;
}
return Math.min(Math.floor(input), DIR_LIST_HARD_MAX_ENTRIES);
}
function classifyFsError(err: unknown): DirListErrCode {
const code = (err as { code?: string } | null)?.code;
if (code === "ENOENT") {
return "NOT_FOUND";
}
if (code === "EACCES" || code === "EPERM") {
return "PERMISSION_DENIED";
}
return "READ_ERROR";
}
export async function handleDirList(params: DirListParams): Promise<DirListResult> {
const requestedPath = params.path;
if (typeof requestedPath !== "string" || requestedPath.length === 0) {
return { ok: false, code: "INVALID_PATH", message: "path required" };
}
if (requestedPath.includes("\0")) {
return { ok: false, code: "INVALID_PATH", message: "path contains NUL byte" };
}
if (!path.isAbsolute(requestedPath)) {
return { ok: false, code: "INVALID_PATH", message: "path must be absolute" };
}
const maxEntries = clampMaxEntries(params.maxEntries);
const offset =
typeof params.pageToken === "string" && params.pageToken.length > 0
? Math.max(0, Number.parseInt(params.pageToken, 10) || 0)
: 0;
const followSymlinks = params.followSymlinks === true;
let canonical: string;
try {
canonical = await fs.realpath(requestedPath);
} catch (err) {
const code = classifyFsError(err);
return {
ok: false,
code,
message: code === "NOT_FOUND" ? "path not found" : `realpath failed: ${String(err)}`,
};
}
if (!followSymlinks && canonical !== requestedPath) {
return {
ok: false,
code: "SYMLINK_REDIRECT",
message: `path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowReadPaths to the canonical path)`,
canonicalPath: canonical,
};
}
let stats: Awaited<ReturnType<typeof fs.stat>>;
try {
stats = await fs.stat(canonical);
} catch (err) {
const code = classifyFsError(err);
return { ok: false, code, message: `stat failed: ${String(err)}`, canonicalPath: canonical };
}
if (!stats.isDirectory()) {
return {
ok: false,
code: "IS_FILE",
message: "path is not a directory",
canonicalPath: canonical,
};
}
let names: string[];
try {
names = await fs.readdir(canonical, { encoding: "utf8" });
} catch (err) {
const code = classifyFsError(err);
return {
ok: false,
code,
message: `readdir failed: ${String(err)}`,
canonicalPath: canonical,
};
}
// Sort by name for stable pagination
names.sort((a, b) => a.localeCompare(b));
const total = names.length;
const page = names.slice(offset, offset + maxEntries);
const truncated = offset + maxEntries < total;
const nextPageToken = truncated ? String(offset + maxEntries) : undefined;
const entries: DirListEntry[] = [];
for (const name of page) {
const entryPath = path.join(canonical, name);
let isDir = false;
let size = 0;
let mtime = 0;
try {
const s = await fs.stat(entryPath);
isDir = s.isDirectory();
size = isDir ? 0 : s.size;
mtime = s.mtimeMs;
} catch {
// stat may fail for broken symlinks; keep zeros and treat as file
}
entries.push({
name,
path: entryPath,
size,
mimeType: isDir ? "inode/directory" : mimeFromExtension(name),
isDir,
mtime,
});
}
return {
ok: true,
path: canonical,
entries,
nextPageToken,
truncated,
};
}

View File

@@ -0,0 +1,203 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
FILE_FETCH_DEFAULT_MAX_BYTES,
FILE_FETCH_HARD_MAX_BYTES,
handleFileFetch,
} from "./file-fetch.js";
let tmpRoot: string;
beforeEach(async () => {
// realpath the mkdtemp result — on macOS /tmp/foo and /var/folders/... are
// symlinks to /private/{tmp,var/folders}, and the new SYMLINK_REDIRECT
// default would otherwise refuse every test path. Tests want to exercise
// the happy path with canonical paths; symlink-specific assertions create
// explicit symlinks inside tmpRoot.
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "file-fetch-test-")));
});
afterEach(async () => {
vi.restoreAllMocks();
await fs.rm(tmpRoot, { recursive: true, force: true });
});
describe("handleFileFetch — input validation", () => {
it("returns INVALID_PATH for empty / non-string path", async () => {
expect(await handleFileFetch({ path: "" })).toMatchObject({
ok: false,
code: "INVALID_PATH",
});
expect(await handleFileFetch({ path: undefined })).toMatchObject({
ok: false,
code: "INVALID_PATH",
});
expect(await handleFileFetch({ path: 42 as unknown })).toMatchObject({
ok: false,
code: "INVALID_PATH",
});
});
it("rejects relative paths", async () => {
const r = await handleFileFetch({ path: "relative/file.txt" });
expect(r).toMatchObject({ ok: false, code: "INVALID_PATH" });
expect(r.ok ? "" : r.message).toMatch(/absolute/);
});
it("rejects paths with NUL bytes", async () => {
const r = await handleFileFetch({ path: "/tmp/foo\0bar" });
expect(r).toMatchObject({ ok: false, code: "INVALID_PATH" });
expect(r.ok ? "" : r.message).toMatch(/NUL/);
});
});
describe("handleFileFetch — fs errors", () => {
it("returns NOT_FOUND for a missing file", async () => {
const target = path.join(tmpRoot, "missing.txt");
expect(await handleFileFetch({ path: target })).toMatchObject({
ok: false,
code: "NOT_FOUND",
});
});
it("returns IS_DIRECTORY when the path resolves to a directory", async () => {
const r = await handleFileFetch({ path: tmpRoot });
expect(r).toMatchObject({ ok: false, code: "IS_DIRECTORY" });
// canonical path is reported back so the caller can re-check policy
expect(r.ok ? null : r.canonicalPath).toBeTruthy();
});
});
describe("handleFileFetch — zero-byte round-trip", () => {
it("fetches an empty file with size=0 and base64=''", async () => {
const target = path.join(tmpRoot, "empty.bin");
await fs.writeFile(target, "");
const r = await handleFileFetch({ path: target });
if (!r.ok) {
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
}
expect(r.size).toBe(0);
expect(r.base64).toBe("");
// SHA-256 of empty input.
expect(r.sha256).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
});
});
describe("handleFileFetch — happy path", () => {
it("reads a small file and returns size + sha256 + base64", async () => {
const target = path.join(tmpRoot, "hello.txt");
const contents = "hello world\n";
await fs.writeFile(target, contents);
const r = await handleFileFetch({ path: target });
if (!r.ok) {
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
}
expect(r.size).toBe(contents.length);
expect(Buffer.from(r.base64, "base64").toString("utf-8")).toBe(contents);
const expectedSha = crypto.createHash("sha256").update(contents).digest("hex");
expect(r.sha256).toBe(expectedSha);
// canonicalized path may differ from input on macOS (/tmp -> /private/tmp)
expect(path.basename(r.path)).toBe("hello.txt");
});
it("preflights canonical path and size without reading bytes", async () => {
const target = path.join(tmpRoot, "hello.txt");
await fs.writeFile(target, "hello world\n");
const readFileSpy = vi.spyOn(fs, "readFile");
const r = await handleFileFetch({ path: target, preflightOnly: true });
expect(r).toMatchObject({
ok: true,
path: target,
size: 12,
base64: "",
sha256: "",
preflightOnly: true,
});
expect(readFileSpy).not.toHaveBeenCalled();
});
it("returns a sensible mime type for known extensions", async () => {
const target = path.join(tmpRoot, "readme.md");
await fs.writeFile(target, "# heading\n");
const r = await handleFileFetch({ path: target });
if (!r.ok) {
throw new Error("expected ok");
}
// libmagic ("file" cli) typically reports text/plain or text/markdown for
// a one-line markdown file; the extension fallback yields text/markdown.
// Accept either.
expect(r.mimeType).toMatch(/^text\/(plain|markdown)$/);
});
});
describe("handleFileFetch — size enforcement", () => {
it("returns FILE_TOO_LARGE when stat size exceeds the cap", async () => {
const target = path.join(tmpRoot, "big.bin");
const data = Buffer.alloc(2048, 0xab);
await fs.writeFile(target, data);
const r = await handleFileFetch({ path: target, maxBytes: 1024 });
expect(r).toMatchObject({ ok: false, code: "FILE_TOO_LARGE" });
});
it("clamps maxBytes to the hard ceiling", async () => {
expect(FILE_FETCH_HARD_MAX_BYTES).toBe(16 * 1024 * 1024);
expect(FILE_FETCH_DEFAULT_MAX_BYTES).toBeLessThanOrEqual(FILE_FETCH_HARD_MAX_BYTES);
// A request asking for a maxBytes well above the hard ceiling should
// still be honored for a small file (no error).
const target = path.join(tmpRoot, "tiny.bin");
await fs.writeFile(target, Buffer.from([0x01, 0x02, 0x03]));
const r = await handleFileFetch({ path: target, maxBytes: Number.MAX_SAFE_INTEGER });
expect(r.ok).toBe(true);
});
it("uses default cap when maxBytes is not finite or non-positive", async () => {
const target = path.join(tmpRoot, "small.bin");
await fs.writeFile(target, Buffer.from([0xff]));
expect(await handleFileFetch({ path: target, maxBytes: -1 })).toMatchObject({ ok: true });
expect(await handleFileFetch({ path: target, maxBytes: Number.NaN })).toMatchObject({
ok: true,
});
expect(await handleFileFetch({ path: target, maxBytes: "8" as unknown })).toMatchObject({
ok: true,
});
});
});
describe("handleFileFetch — symlink handling", () => {
it("refuses to follow a symlink by default (SYMLINK_REDIRECT)", async () => {
const real = path.join(tmpRoot, "real.txt");
const link = path.join(tmpRoot, "link.txt");
await fs.writeFile(real, "data");
await fs.symlink(real, link);
const r = await handleFileFetch({ path: link });
expect(r).toMatchObject({ ok: false, code: "SYMLINK_REDIRECT" });
// Caller learns the canonical target so the operator can update the
// allowlist or set followSymlinks=true.
expect(r.ok ? null : r.canonicalPath).toBe(real);
});
it("follows symlinks and returns the canonical path when followSymlinks=true", async () => {
const real = path.join(tmpRoot, "real.txt");
const link = path.join(tmpRoot, "link.txt");
await fs.writeFile(real, "data");
await fs.symlink(real, link);
const r = await handleFileFetch({ path: link, followSymlinks: true });
if (!r.ok) {
throw new Error(`expected ok, got ${r.code}`);
}
expect(path.basename(r.path)).toBe("real.txt");
});
});

View File

@@ -0,0 +1,203 @@
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { EXTENSION_MIME } from "../shared/mime.js";
export const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
export const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
export type FileFetchParams = {
path?: unknown;
maxBytes?: unknown;
followSymlinks?: unknown;
preflightOnly?: unknown;
};
export type FileFetchOk = {
ok: true;
path: string;
size: number;
mimeType: string;
base64: string;
sha256: string;
preflightOnly?: boolean;
};
export type FileFetchErrCode =
| "INVALID_PATH"
| "NOT_FOUND"
| "PERMISSION_DENIED"
| "IS_DIRECTORY"
| "FILE_TOO_LARGE"
| "PATH_TRAVERSAL"
| "SYMLINK_REDIRECT"
| "READ_ERROR";
export type FileFetchErr = {
ok: false;
code: FileFetchErrCode;
message: string;
canonicalPath?: string;
};
export type FileFetchResult = FileFetchOk | FileFetchErr;
function detectMimeType(filePath: string): string {
if (process.platform !== "win32") {
try {
const result = spawnSync("file", ["-b", "--mime-type", filePath], {
encoding: "utf-8",
timeout: 2000,
});
const stdout = result.stdout?.trim();
if (result.status === 0 && stdout) {
return stdout;
}
} catch {
// fall through to extension fallback
}
}
const ext = path.extname(filePath).toLowerCase();
return EXTENSION_MIME[ext] ?? "application/octet-stream";
}
function clampMaxBytes(input: unknown): number {
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
return FILE_FETCH_DEFAULT_MAX_BYTES;
}
return Math.min(Math.floor(input), FILE_FETCH_HARD_MAX_BYTES);
}
function classifyFsError(err: unknown): FileFetchErrCode {
const code = (err as { code?: string } | null)?.code;
if (code === "ENOENT") {
return "NOT_FOUND";
}
if (code === "EACCES" || code === "EPERM") {
return "PERMISSION_DENIED";
}
if (code === "EISDIR") {
return "IS_DIRECTORY";
}
return "READ_ERROR";
}
export async function handleFileFetch(params: FileFetchParams): Promise<FileFetchResult> {
const requestedPath = params.path;
if (typeof requestedPath !== "string" || requestedPath.length === 0) {
return { ok: false, code: "INVALID_PATH", message: "path required" };
}
if (requestedPath.includes("\0")) {
return { ok: false, code: "INVALID_PATH", message: "path contains NUL byte" };
}
if (!path.isAbsolute(requestedPath)) {
return { ok: false, code: "INVALID_PATH", message: "path must be absolute" };
}
const maxBytes = clampMaxBytes(params.maxBytes);
const followSymlinks = params.followSymlinks === true;
const preflightOnly = params.preflightOnly === true;
let canonical: string;
try {
canonical = await fs.realpath(requestedPath);
} catch (err) {
const code = classifyFsError(err);
return {
ok: false,
code,
message: code === "NOT_FOUND" ? "file not found" : `realpath failed: ${String(err)}`,
};
}
// Refuse to follow symlinks anywhere in the path unless the operator
// has explicitly opted in. A symlink in user-controlled territory
// (e.g. ~/Downloads/evil → /etc) could redirect an allowed-looking
// request to a disallowed canonical target. The error includes the
// canonical path so the operator can either update their allowlist
// to the canonical form or set followSymlinks=true on this node.
if (!followSymlinks && canonical !== requestedPath) {
return {
ok: false,
code: "SYMLINK_REDIRECT",
message: `path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowReadPaths to the canonical path)`,
canonicalPath: canonical,
};
}
let stats: Awaited<ReturnType<typeof fs.stat>>;
try {
stats = await fs.stat(canonical);
} catch (err) {
const code = classifyFsError(err);
return { ok: false, code, message: `stat failed: ${String(err)}`, canonicalPath: canonical };
}
if (stats.isDirectory()) {
return {
ok: false,
code: "IS_DIRECTORY",
message: "path is a directory",
canonicalPath: canonical,
};
}
if (!stats.isFile()) {
return {
ok: false,
code: "READ_ERROR",
message: "path is not a regular file",
canonicalPath: canonical,
};
}
if (stats.size > maxBytes) {
return {
ok: false,
code: "FILE_TOO_LARGE",
message: `file size ${stats.size} exceeds limit ${maxBytes}`,
canonicalPath: canonical,
};
}
if (preflightOnly) {
return {
ok: true,
path: canonical,
size: stats.size,
mimeType: "",
base64: "",
sha256: "",
preflightOnly: true,
};
}
let buffer: Buffer;
try {
buffer = await fs.readFile(canonical);
} catch (err) {
const code = classifyFsError(err);
return { ok: false, code, message: `read failed: ${String(err)}`, canonicalPath: canonical };
}
if (buffer.byteLength > maxBytes) {
return {
ok: false,
code: "FILE_TOO_LARGE",
message: `read ${buffer.byteLength} bytes exceeds limit ${maxBytes}`,
canonicalPath: canonical,
};
}
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
const base64 = buffer.toString("base64");
const mimeType = detectMimeType(canonical);
return {
ok: true,
path: canonical,
size: buffer.byteLength,
mimeType,
base64,
sha256,
};
}

View File

@@ -0,0 +1,357 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { handleFileWrite } from "./file-write.js";
let tmpRoot: string;
beforeEach(async () => {
// realpath: see file-fetch.test.ts for the macOS symlinked-tmpdir reason.
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "file-write-test-")));
});
afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true });
});
function b64(s: string): string {
return Buffer.from(s, "utf-8").toString("base64");
}
describe("handleFileWrite — input validation", () => {
it("rejects empty / non-string path", async () => {
expect(await handleFileWrite({ path: "", contentBase64: b64("x") })).toMatchObject({
ok: false,
code: "INVALID_PATH",
});
});
it("rejects relative paths", async () => {
const r = await handleFileWrite({ path: "relative.txt", contentBase64: b64("x") });
expect(r).toMatchObject({ ok: false, code: "INVALID_PATH" });
});
it("rejects paths with NUL bytes", async () => {
const r = await handleFileWrite({ path: "/tmp/foo\0bar", contentBase64: b64("x") });
expect(r).toMatchObject({ ok: false, code: "INVALID_PATH" });
});
it("requires contentBase64 but allows an empty encoded payload", async () => {
const missing = await handleFileWrite({ path: path.join(tmpRoot, "missing.bin") });
expect(missing).toMatchObject({ ok: false, code: "INVALID_BASE64" });
const target = path.join(tmpRoot, "empty.bin");
const empty = await handleFileWrite({ path: target, contentBase64: "" });
expect(empty).toMatchObject({
ok: true,
size: 0,
sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
});
expect(await fs.readFile(target)).toHaveLength(0);
});
});
describe("handleFileWrite — happy path", () => {
it("writes a new file and returns size + sha256 + overwritten=false", async () => {
const target = path.join(tmpRoot, "out.txt");
const contents = "hello write\n";
const r = await handleFileWrite({ path: target, contentBase64: b64(contents) });
if (!r.ok) {
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
}
expect(r.size).toBe(contents.length);
expect(r.overwritten).toBe(false);
const expectedSha = crypto.createHash("sha256").update(contents).digest("hex");
expect(r.sha256).toBe(expectedSha);
const onDisk = await fs.readFile(target, "utf-8");
expect(onDisk).toBe(contents);
});
it("does not leave .tmp files behind on success", async () => {
const target = path.join(tmpRoot, "atomic.txt");
const r = await handleFileWrite({ path: target, contentBase64: b64("body") });
expect(r.ok).toBe(true);
const entries = await fs.readdir(tmpRoot);
const tmpFiles = entries.filter((n) => n.includes(".tmp"));
expect(tmpFiles).toEqual([]);
});
});
describe("handleFileWrite — overwrite policy", () => {
it("refuses to overwrite an existing file when overwrite=false", async () => {
const target = path.join(tmpRoot, "exists.txt");
await fs.writeFile(target, "before");
const r = await handleFileWrite({
path: target,
contentBase64: b64("after"),
overwrite: false,
});
expect(r).toMatchObject({ ok: false, code: "EXISTS_NO_OVERWRITE" });
expect(await fs.readFile(target, "utf-8")).toBe("before");
});
it("overwrites and reports overwritten=true when overwrite=true", async () => {
const target = path.join(tmpRoot, "exists.txt");
await fs.writeFile(target, "before");
const r = await handleFileWrite({
path: target,
contentBase64: b64("after"),
overwrite: true,
});
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.overwritten).toBe(true);
expect(await fs.readFile(target, "utf-8")).toBe("after");
});
});
describe("handleFileWrite — parent directory handling", () => {
it("returns PARENT_NOT_FOUND when parent is missing and createParents=false", async () => {
const target = path.join(tmpRoot, "nested", "child.txt");
const r = await handleFileWrite({
path: target,
contentBase64: b64("x"),
createParents: false,
});
expect(r).toMatchObject({ ok: false, code: "PARENT_NOT_FOUND" });
});
it("creates missing parents when createParents=true", async () => {
const target = path.join(tmpRoot, "deep", "nested", "child.txt");
const r = await handleFileWrite({
path: target,
contentBase64: b64("x"),
createParents: true,
});
expect(r.ok).toBe(true);
expect(await fs.readFile(target, "utf-8")).toBe("x");
});
});
describe("handleFileWrite — symlink protection", () => {
it("refuses to write through an existing symlink (lstat)", async () => {
const real = path.join(tmpRoot, "real.txt");
const link = path.join(tmpRoot, "link.txt");
await fs.writeFile(real, "untouched");
await fs.symlink(real, link);
const r = await handleFileWrite({
path: link,
contentBase64: b64("evil"),
overwrite: true,
});
expect(r).toMatchObject({ ok: false, code: "SYMLINK_TARGET_DENIED" });
// The original file must be unchanged.
expect(await fs.readFile(real, "utf-8")).toBe("untouched");
});
it("refuses to write through a symlink in a parent directory by default", async () => {
// realDir is the actual victim; sentinel is a pre-existing file in it.
const realDir = path.join(tmpRoot, "real-dir");
await fs.mkdir(realDir);
const sentinel = path.join(realDir, "sentinel.txt");
await fs.writeFile(sentinel, "DO_NOT_TOUCH");
// /tmpRoot/allowed -> /tmpRoot/real-dir (symlink in a parent segment).
const allowed = path.join(tmpRoot, "allowed");
await fs.symlink(realDir, allowed);
// Asking to write to .../allowed/new-file.txt — the lexical parent
// (.../allowed) resolves through a symlink to .../real-dir. Refuse.
const r = await handleFileWrite({
path: path.join(allowed, "new-file.txt"),
contentBase64: b64("payload"),
});
expect(r).toMatchObject({ ok: false, code: "SYMLINK_REDIRECT" });
// The error includes the canonical target so the operator can
// either update allowWritePaths or set followSymlinks=true.
expect(r.ok ? null : r.canonicalPath).toBe(path.join(realDir, "new-file.txt"));
// No file was created at the canonical target.
await expect(fs.access(path.join(realDir, "new-file.txt"))).rejects.toMatchObject({
code: "ENOENT",
});
// Sentinel must be untouched.
expect(await fs.readFile(sentinel, "utf-8")).toBe("DO_NOT_TOUCH");
});
it("checks symlinked parents before recursive mkdir", async () => {
const realDir = path.join(tmpRoot, "real-dir");
await fs.mkdir(realDir);
const allowed = path.join(tmpRoot, "allowed");
await fs.symlink(realDir, allowed);
const r = await handleFileWrite({
path: path.join(allowed, "new", "child.txt"),
contentBase64: b64("payload"),
createParents: true,
});
expect(r).toMatchObject({ ok: false, code: "SYMLINK_REDIRECT" });
expect(r.ok ? null : r.canonicalPath).toBe(path.join(realDir, "new", "child.txt"));
await expect(fs.access(path.join(realDir, "new"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("follows the parent symlink when followSymlinks=true", async () => {
const realDir = path.join(tmpRoot, "real-dir");
await fs.mkdir(realDir);
const allowed = path.join(tmpRoot, "allowed");
await fs.symlink(realDir, allowed);
const r = await handleFileWrite({
path: path.join(allowed, "new-file.txt"),
contentBase64: b64("payload"),
followSymlinks: true,
});
expect(r.ok).toBe(true);
// The file landed in the canonical (real) directory.
expect(await fs.readFile(path.join(realDir, "new-file.txt"), "utf-8")).toBe("payload");
});
it("preflights canonical write targets without creating files or parents", async () => {
const realDir = path.join(tmpRoot, "real-dir");
await fs.mkdir(realDir);
const allowed = path.join(tmpRoot, "allowed");
await fs.symlink(realDir, allowed);
const r = await handleFileWrite({
path: path.join(allowed, "new", "child.txt"),
contentBase64: b64("payload"),
createParents: true,
followSymlinks: true,
preflightOnly: true,
});
expect(r).toMatchObject({
ok: true,
path: path.join(realDir, "new", "child.txt"),
size: "payload".length,
});
await expect(fs.access(path.join(realDir, "new"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("refuses to overwrite a directory", async () => {
const target = path.join(tmpRoot, "is-a-dir");
await fs.mkdir(target);
const r = await handleFileWrite({
path: target,
contentBase64: b64("x"),
overwrite: true,
});
expect(r).toMatchObject({ ok: false, code: "IS_DIRECTORY" });
});
});
describe("handleFileWrite — integrity check", () => {
it("returns INTEGRITY_FAILURE before writing when expectedSha256 mismatches", async () => {
const target = path.join(tmpRoot, "checked.txt");
const r = await handleFileWrite({
path: target,
contentBase64: b64("real-content"),
expectedSha256: "0".repeat(64),
});
expect(r).toMatchObject({ ok: false, code: "INTEGRITY_FAILURE" });
// The file must never be created on a mismatch.
await expect(fs.access(target)).rejects.toMatchObject({ code: "ENOENT" });
});
it("does NOT replace or delete an existing file when overwrite=true and expectedSha256 mismatches", async () => {
const target = path.join(tmpRoot, "victim.txt");
await fs.writeFile(target, "ORIGINAL_CONTENT_DO_NOT_TOUCH");
const r = await handleFileWrite({
path: target,
contentBase64: b64("attacker-content"),
overwrite: true,
expectedSha256: "0".repeat(64),
});
expect(r).toMatchObject({ ok: false, code: "INTEGRITY_FAILURE" });
// Critical: the original must survive. A bad caller hash must not
// be a primitive for replacing-then-deleting an existing file.
expect(await fs.readFile(target, "utf-8")).toBe("ORIGINAL_CONTENT_DO_NOT_TOUCH");
});
it("accepts a matching expectedSha256 and keeps the file", async () => {
const target = path.join(tmpRoot, "checked.txt");
const contents = "real-content";
const sha = crypto.createHash("sha256").update(contents).digest("hex");
const r = await handleFileWrite({
path: target,
contentBase64: b64(contents),
expectedSha256: sha,
});
expect(r.ok).toBe(true);
expect(await fs.readFile(target, "utf-8")).toBe(contents);
});
it("treats expectedSha256 as case-insensitive", async () => {
const target = path.join(tmpRoot, "checked.txt");
const contents = "abc";
const sha = crypto.createHash("sha256").update(contents).digest("hex").toUpperCase();
const r = await handleFileWrite({
path: target,
contentBase64: b64(contents),
expectedSha256: sha,
});
expect(r.ok).toBe(true);
});
});
describe("handleFileWrite — base64 round-trip validation", () => {
it("rejects malformed base64 that silently drops characters", async () => {
const target = path.join(tmpRoot, "bad.bin");
// "@" is not in the base64 alphabet — Buffer.from would silently drop
// it and decode "AAA" instead of failing.
const r = await handleFileWrite({
path: target,
contentBase64: "AAA@@@",
});
expect(r).toMatchObject({ ok: false, code: "INVALID_BASE64" });
await expect(fs.access(target)).rejects.toMatchObject({ code: "ENOENT" });
});
it("accepts standard base64 with and without padding", async () => {
const target = path.join(tmpRoot, "padded.bin");
// Buffer.from("hi") -> "aGk=" with padding, "aGk" without.
const r1 = await handleFileWrite({ path: target, contentBase64: "aGk=" });
expect(r1.ok).toBe(true);
const target2 = path.join(tmpRoot, "unpadded.bin");
const r2 = await handleFileWrite({ path: target2, contentBase64: "aGk" });
expect(r2.ok).toBe(true);
});
it("accepts base64url variant (-_ instead of +/)", async () => {
const target = path.join(tmpRoot, "url.bin");
// Buffer.from([0xfb, 0xff]) -> "+/8=" standard, "-_8=" url
const r = await handleFileWrite({ path: target, contentBase64: "-_8=" });
expect(r.ok).toBe(true);
});
});
describe("handleFileWrite — size cap", () => {
it("rejects content larger than the 16MB cap", async () => {
const target = path.join(tmpRoot, "big.bin");
// 17MB of zero-bytes — base64 inflates by ~4/3 but we're checking the
// decoded buffer length so this is fine.
const big = Buffer.alloc(17 * 1024 * 1024, 0);
const r = await handleFileWrite({
path: target,
contentBase64: big.toString("base64"),
});
expect(r).toMatchObject({ ok: false, code: "FILE_TOO_LARGE" });
});
});

View File

@@ -0,0 +1,314 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
const MAX_CONTENT_BYTES = 16 * 1024 * 1024; // 16 MB
type FileWriteParams = {
path: string;
contentBase64: string;
overwrite: boolean;
createParents: boolean;
expectedSha256?: string;
followSymlinks?: boolean;
preflightOnly?: boolean;
};
type FileWriteSuccess = {
ok: true;
path: string;
size: number;
sha256: string;
overwritten: boolean;
};
type FileWriteError = {
ok: false;
code: string;
message: string;
canonicalPath?: string;
};
type FileWriteResult = FileWriteSuccess | FileWriteError;
function sha256Hex(buf: Buffer): string {
return crypto.createHash("sha256").update(buf).digest("hex");
}
function err(code: string, message: string, canonicalPath?: string): FileWriteError {
return { ok: false, code, message, ...(canonicalPath ? { canonicalPath } : {}) };
}
async function pathExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function findExistingAncestor(p: string): Promise<string | null> {
let current = p;
while (true) {
try {
await fs.lstat(current);
return current;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
}
async function canonicalTargetFromExistingAncestor(targetPath: string): Promise<string> {
const ancestor = await findExistingAncestor(targetPath);
if (!ancestor) {
return targetPath;
}
let canonicalAncestor: string;
try {
canonicalAncestor = await fs.realpath(ancestor);
} catch {
canonicalAncestor = ancestor;
}
const relative = path.relative(ancestor, targetPath);
return relative ? path.join(canonicalAncestor, relative) : canonicalAncestor;
}
async function rejectParentSymlinkRedirect(
targetPath: string,
parentDir: string,
): Promise<FileWriteError | null> {
const ancestor = await findExistingAncestor(parentDir);
if (!ancestor) {
return null;
}
let canonicalAncestor: string;
try {
canonicalAncestor = await fs.realpath(ancestor);
} catch {
return null;
}
if (canonicalAncestor === ancestor) {
return null;
}
const canonicalTarget = path.join(canonicalAncestor, path.relative(ancestor, targetPath));
return err(
"SYMLINK_REDIRECT",
`parent ${ancestor} resolves through a symlink to ${canonicalAncestor}; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowWritePaths to the canonical path)`,
canonicalTarget,
);
}
export async function handleFileWrite(
params: Partial<FileWriteParams> & Record<string, unknown>,
): Promise<FileWriteResult> {
const rawPath = typeof params?.path === "string" ? params.path : "";
const hasContentBase64 = typeof params?.contentBase64 === "string";
const contentBase64 = hasContentBase64 ? (params.contentBase64 as string) : "";
const overwrite = params?.overwrite === true;
const createParents = params?.createParents === true;
const expectedSha256 =
typeof params?.expectedSha256 === "string" ? params.expectedSha256 : undefined;
const followSymlinks = params?.followSymlinks === true;
const preflightOnly = params?.preflightOnly === true;
// 1. Validate path: must be absolute, non-empty, no NUL byte
if (!rawPath) {
return err("INVALID_PATH", "path is required");
}
if (rawPath.includes("\0")) {
return err("INVALID_PATH", "path must not contain NUL bytes");
}
if (!path.isAbsolute(rawPath)) {
return err("INVALID_PATH", "path must be absolute");
}
if (!hasContentBase64) {
return err("INVALID_BASE64", "contentBase64 is required");
}
// 2. Decode base64 → Buffer.
// Buffer.from(s, "base64") in Node never throws — it silently drops
// non-base64 characters and returns whatever it could decode. That
// means a typo or truncated input would land garbage on disk if we
// accepted whatever decoded. Defense: round-trip the decoded buffer
// back to base64 and compare against the input modulo padding/url
// variants. A mismatch means characters were silently dropped.
const buf = Buffer.from(contentBase64, "base64");
const reEncoded = buf.toString("base64");
// Normalize: drop padding and convert base64url chars to standard so the
// comparison tolerates both "=" / no-"=" inputs and "-_" base64url.
const normalize = (s: string): string =>
s.replace(/=+$/u, "").replace(/-/gu, "+").replace(/_/gu, "/");
if (normalize(reEncoded) !== normalize(contentBase64)) {
return err("INVALID_BASE64", "contentBase64 is not valid base64");
}
if (buf.length > MAX_CONTENT_BYTES) {
return err(
"FILE_TOO_LARGE",
`decoded content is ${buf.length} bytes; maximum is ${MAX_CONTENT_BYTES} bytes (16 MB)`,
);
}
// 3. Resolve parent dir
const targetPath = path.normalize(rawPath);
const parentDir = path.dirname(targetPath);
const parentExists = await pathExists(parentDir);
// Refuse symlink traversal in the existing parent chain before creating
// missing directories. Recursive mkdir follows symlinked ancestors, so this
// has to run before mkdir can mutate the canonical target.
if (!followSymlinks) {
const redirect = await rejectParentSymlinkRedirect(targetPath, parentDir);
if (redirect) {
return redirect;
}
}
if (!parentExists) {
if (!createParents) {
return err("PARENT_NOT_FOUND", `parent directory does not exist: ${parentDir}`);
}
if (preflightOnly) {
const computedSha256 = sha256Hex(buf);
if (expectedSha256 && expectedSha256.toLowerCase() !== computedSha256) {
return err(
"INTEGRITY_FAILURE",
`sha256 mismatch: expected ${expectedSha256.toLowerCase()}, got ${computedSha256}`,
targetPath,
);
}
return {
ok: true,
path: await canonicalTargetFromExistingAncestor(targetPath),
size: buf.length,
sha256: computedSha256,
overwritten: false,
};
}
try {
await fs.mkdir(parentDir, { recursive: true });
} catch (mkdirErr) {
const message = mkdirErr instanceof Error ? mkdirErr.message : String(mkdirErr);
return err("WRITE_ERROR", `failed to create parent directories: ${message}`);
}
}
// Re-check after mkdir as a race-defense: if the parent chain changed
// between the first check and directory creation, fail before writing bytes.
if (!followSymlinks) {
const redirect = await rejectParentSymlinkRedirect(targetPath, parentDir);
if (redirect) {
return redirect;
}
}
let overwritten = false;
try {
const existingLStat = await fs.lstat(targetPath);
if (existingLStat.isSymbolicLink()) {
return err(
"SYMLINK_TARGET_DENIED",
`path is a symlink; refusing to write through it: ${targetPath}`,
);
}
if (existingLStat.isDirectory()) {
return err("IS_DIRECTORY", `path resolves to a directory: ${targetPath}`);
}
if (!overwrite) {
return err(
"EXISTS_NO_OVERWRITE",
`file already exists and overwrite is false: ${targetPath}`,
);
}
overwritten = true;
} catch (statErr: unknown) {
// ENOENT is fine — file does not exist yet
if ((statErr as NodeJS.ErrnoException).code !== "ENOENT") {
const message = statErr instanceof Error ? statErr.message : String(statErr);
if (message.toLowerCase().includes("permission")) {
return err("PERMISSION_DENIED", `permission denied: ${targetPath}`);
}
return err("WRITE_ERROR", `unexpected stat error: ${message}`);
}
}
// 5. Hash the decoded buffer BEFORE touching disk. If the caller
// supplied expectedSha256 and it doesn't match, refuse outright so
// a bad caller hash with overwrite=true can't replace + delete the
// original. Computing from the buffer (not a re-read) is the right
// source of truth — the caller asked us to write THESE bytes.
const computedSha256 = sha256Hex(buf);
if (expectedSha256 && expectedSha256.toLowerCase() !== computedSha256) {
return err(
"INTEGRITY_FAILURE",
`sha256 mismatch: expected ${expectedSha256.toLowerCase()}, got ${computedSha256}`,
targetPath,
);
}
if (preflightOnly) {
return {
ok: true,
path: await canonicalTargetFromExistingAncestor(targetPath),
size: buf.length,
sha256: computedSha256,
overwritten,
};
}
// 6. Atomic write: write to tmp, then rename
const tmpSuffix = crypto.randomBytes(8).toString("hex");
const tmpPath = `${targetPath}.${tmpSuffix}.tmp`;
try {
await fs.writeFile(tmpPath, buf);
} catch (writeErr) {
const message = writeErr instanceof Error ? writeErr.message : String(writeErr);
// Clean up tmp if possible
await fs.unlink(tmpPath).catch(() => {});
if (message.toLowerCase().includes("permission") || message.toLowerCase().includes("access")) {
return err("PERMISSION_DENIED", `permission denied writing to: ${parentDir}`);
}
return err("WRITE_ERROR", `failed to write file: ${message}`);
}
try {
await fs.rename(tmpPath, targetPath);
} catch (renameErr) {
const message = renameErr instanceof Error ? renameErr.message : String(renameErr);
await fs.unlink(tmpPath).catch(() => {});
if (message.toLowerCase().includes("permission") || message.toLowerCase().includes("access")) {
return err("PERMISSION_DENIED", `permission denied renaming to: ${targetPath}`);
}
return err("WRITE_ERROR", `failed to rename tmp to target: ${message}`);
}
const writtenBuf = buf;
// 8. Re-realpath to resolve any symlinks in the final path
let canonicalPath = targetPath;
try {
canonicalPath = await fs.realpath(targetPath);
} catch {
// Best effort; use normalized path as fallback
canonicalPath = targetPath;
}
return {
ok: true,
path: canonicalPath,
size: writtenBuf.length,
sha256: computedSha256,
overwritten,
};
}

View File

@@ -0,0 +1,93 @@
// Append-only audit log for file-transfer operations.
//
// Records every decision (allow/deny/error) at the gateway-side tool
// layer. Lands at ~/.openclaw/audit/file-transfer.jsonl. Rotation is
// caller's responsibility — the file grows unbounded.
//
// Log records do NOT include file contents or hashes of secrets. They do
// include canonical paths and sha256 of the payload, so treat the audit
// file as sensitive.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export type FileTransferAuditOp = "file.fetch" | "dir.list" | "dir.fetch" | "file.write";
export type FileTransferAuditDecision =
| "allowed"
| "allowed:once"
| "allowed:always"
| "denied:no_policy"
| "denied:policy"
| "denied:approval"
| "denied:command_not_allowed"
| "denied:symlink_escape"
| "error";
export type FileTransferAuditRecord = {
timestamp: string;
op: FileTransferAuditOp;
nodeId: string;
nodeDisplayName?: string;
requestedPath: string;
canonicalPath?: string;
decision: FileTransferAuditDecision;
errorCode?: string;
errorMessage?: string;
sizeBytes?: number;
sha256?: string;
durationMs?: number;
// Tying back to the agent that initiated the op
requesterAgentId?: string;
sessionKey?: string;
// Reason text for denials
reason?: string;
};
let auditDirPromise: Promise<string> | null = null;
async function ensureAuditDir(): Promise<string> {
if (auditDirPromise) {
return auditDirPromise;
}
const promise = (async () => {
const dir = path.join(os.homedir(), ".openclaw", "audit");
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
return dir;
})();
// If the mkdir rejects (transient permission error etc.), clear the
// cached singleton so the NEXT call retries instead of permanently
// silencing the audit log.
promise.catch(() => {
if (auditDirPromise === promise) {
auditDirPromise = null;
}
});
auditDirPromise = promise;
return promise;
}
function auditFilePath(dir: string): string {
return path.join(dir, "file-transfer.jsonl");
}
/**
* Append an audit record. Best-effort — failures are logged to stderr and
* never propagated to the caller (the caller's operation is the source of
* truth, not the audit write).
*/
export async function appendFileTransferAudit(
record: Omit<FileTransferAuditRecord, "timestamp">,
): Promise<void> {
try {
const dir = await ensureAuditDir();
const line = `${JSON.stringify({
timestamp: new Date().toISOString(),
...record,
})}\n`;
await fs.appendFile(auditFilePath(dir), line, { mode: 0o600 });
} catch (e) {
process.stderr.write(`[file-transfer:audit] append failed: ${String(e)}\n`);
}
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { classifyFsError, err, throwFromNodePayload } from "./errors.js";
describe("err", () => {
it("returns an error envelope without canonicalPath when omitted", () => {
const e = err("INVALID_PATH", "path required");
expect(e).toEqual({ ok: false, code: "INVALID_PATH", message: "path required" });
expect("canonicalPath" in e).toBe(false);
});
it("includes canonicalPath only when provided non-empty", () => {
const withPath = err("NOT_FOUND", "missing", "/tmp/x");
expect(withPath.canonicalPath).toBe("/tmp/x");
const blankPath = err("NOT_FOUND", "missing", "");
expect("canonicalPath" in blankPath).toBe(false);
});
});
describe("classifyFsError", () => {
it("maps ENOENT to NOT_FOUND", () => {
expect(classifyFsError({ code: "ENOENT" })).toBe("NOT_FOUND");
});
it("maps EACCES and EPERM to PERMISSION_DENIED", () => {
expect(classifyFsError({ code: "EACCES" })).toBe("PERMISSION_DENIED");
expect(classifyFsError({ code: "EPERM" })).toBe("PERMISSION_DENIED");
});
it("maps EISDIR to IS_DIRECTORY", () => {
expect(classifyFsError({ code: "EISDIR" })).toBe("IS_DIRECTORY");
});
it("falls back to READ_ERROR for unknown / null / non-object input", () => {
expect(classifyFsError({ code: "EUNKNOWN" })).toBe("READ_ERROR");
expect(classifyFsError(null)).toBe("READ_ERROR");
expect(classifyFsError(undefined)).toBe("READ_ERROR");
expect(classifyFsError("nope")).toBe("READ_ERROR");
});
});
describe("throwFromNodePayload", () => {
it("preserves code and message in the thrown Error", () => {
expect(() =>
throwFromNodePayload("file.fetch", { code: "NOT_FOUND", message: "file not found" }),
).toThrow(/file\.fetch NOT_FOUND: file not found/);
});
it("appends canonicalPath when present", () => {
expect(() =>
throwFromNodePayload("file.fetch", {
code: "POLICY_DENIED",
message: "blocked",
canonicalPath: "/tmp/x",
}),
).toThrow(/canonical=\/tmp\/x/);
});
it("falls back to ERROR / generic message when fields are missing", () => {
expect(() => throwFromNodePayload("dir.list", {})).toThrow(/dir\.list ERROR: dir\.list failed/);
});
});

View File

@@ -0,0 +1,68 @@
// Shared error code surface across the four file-transfer tools/handlers.
// Every tool returns the same { ok: false, code, message, canonicalPath? }
// shape so the model can reason about errors uniformly.
export type FileTransferErrCode =
// Path-shape errors (caller's fault)
| "INVALID_PATH"
| "INVALID_BASE64"
| "INVALID_PARAMS"
// Filesystem errors (file/dir layer)
| "NOT_FOUND"
| "PERMISSION_DENIED"
| "IS_DIRECTORY"
| "IS_FILE"
| "PARENT_NOT_FOUND"
| "EXISTS_NO_OVERWRITE"
| "READ_ERROR"
| "WRITE_ERROR"
// Size/limit errors
| "FILE_TOO_LARGE"
| "TREE_TOO_LARGE"
// Safety errors
| "PATH_TRAVERSAL"
| "SYMLINK_TARGET_DENIED"
| "INTEGRITY_FAILURE"
// Policy errors (gateway-side)
| "POLICY_DENIED"
| "NO_POLICY";
export type FileTransferErr = {
ok: false;
code: FileTransferErrCode;
message: string;
canonicalPath?: string;
};
export function err(
code: FileTransferErrCode,
message: string,
canonicalPath?: string,
): FileTransferErr {
return { ok: false, code, message, ...(canonicalPath ? { canonicalPath } : {}) };
}
// Translate a node-side fs error to a public error code.
export function classifyFsError(e: unknown): FileTransferErrCode {
const code = (e as { code?: string } | null)?.code;
if (code === "ENOENT") {
return "NOT_FOUND";
}
if (code === "EACCES" || code === "EPERM") {
return "PERMISSION_DENIED";
}
if (code === "EISDIR") {
return "IS_DIRECTORY";
}
return "READ_ERROR";
}
// Convert a node-host error payload to a thrown Error for agent-tool consumption.
// The agent-tool surfaces these as failed tool results uniformly.
export function throwFromNodePayload(operation: string, payload: Record<string, unknown>): never {
const code = typeof payload.code === "string" ? payload.code : "ERROR";
const message = typeof payload.message === "string" ? payload.message : `${operation} failed`;
const canonical =
typeof payload.canonicalPath === "string" ? ` (canonical=${payload.canonicalPath})` : "";
throw new Error(`${operation} ${code}: ${message}${canonical}`);
}

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
EXTENSION_MIME,
IMAGE_MIME_INLINE_SET,
TEXT_INLINE_MAX_BYTES,
TEXT_INLINE_MIME_SET,
mimeFromExtension,
} from "./mime.js";
describe("mimeFromExtension", () => {
it("returns the mapped mime for known extensions", () => {
expect(mimeFromExtension("foo.png")).toBe("image/png");
expect(mimeFromExtension("/abs/path/bar.JPG")).toBe("image/jpeg");
expect(mimeFromExtension("doc.pdf")).toBe("application/pdf");
expect(mimeFromExtension("notes.md")).toBe("text/markdown");
});
it("falls back to application/octet-stream for unknown extensions", () => {
expect(mimeFromExtension("blob.xyz")).toBe("application/octet-stream");
expect(mimeFromExtension("Makefile")).toBe("application/octet-stream");
});
it("is case-insensitive on the extension", () => {
expect(mimeFromExtension("foo.PNG")).toBe("image/png");
expect(mimeFromExtension("foo.WeBp")).toBe("image/webp");
});
});
describe("MIME constants", () => {
it("EXTENSION_MIME includes the v1 image set", () => {
expect(EXTENSION_MIME[".png"]).toBe("image/png");
expect(EXTENSION_MIME[".jpg"]).toBe("image/jpeg");
expect(EXTENSION_MIME[".jpeg"]).toBe("image/jpeg");
expect(EXTENSION_MIME[".webp"]).toBe("image/webp");
expect(EXTENSION_MIME[".gif"]).toBe("image/gif");
});
it("IMAGE_MIME_INLINE_SET is the inline-renderable image set", () => {
expect(IMAGE_MIME_INLINE_SET.has("image/png")).toBe(true);
expect(IMAGE_MIME_INLINE_SET.has("image/jpeg")).toBe(true);
expect(IMAGE_MIME_INLINE_SET.has("image/webp")).toBe(true);
expect(IMAGE_MIME_INLINE_SET.has("image/gif")).toBe(true);
// heic/heif intentionally excluded
expect(IMAGE_MIME_INLINE_SET.has("image/heic")).toBe(false);
expect(IMAGE_MIME_INLINE_SET.has("image/heif")).toBe(false);
});
it("TEXT_INLINE_MIME_SET covers small-text inlining types", () => {
expect(TEXT_INLINE_MIME_SET.has("text/plain")).toBe(true);
expect(TEXT_INLINE_MIME_SET.has("text/markdown")).toBe(true);
expect(TEXT_INLINE_MIME_SET.has("application/json")).toBe(true);
expect(TEXT_INLINE_MIME_SET.has("text/csv")).toBe(true);
});
it("TEXT_INLINE_MAX_BYTES is the documented 8KB cap", () => {
expect(TEXT_INLINE_MAX_BYTES).toBe(8 * 1024);
});
});

View File

@@ -0,0 +1,53 @@
import path from "node:path";
// Single source of truth for extension→MIME mapping. Used by all four
// handlers/tools so adding a new extension lands everywhere at once.
export const EXTENSION_MIME: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".heic": "image/heic",
".heif": "image/heif",
".pdf": "application/pdf",
".txt": "text/plain",
".log": "text/plain",
".md": "text/markdown",
".json": "application/json",
".csv": "text/csv",
".html": "text/html",
".xml": "application/xml",
".zip": "application/zip",
".tar": "application/x-tar",
".gz": "application/gzip",
};
// MIME types we treat as inline-displayable images for vision-capable models.
// Note: heic/heif are detectable but not all providers can render them, so we
// leave them out of the inline-image set and let them flow as text+saved-path.
export const IMAGE_MIME_INLINE_SET = new Set([
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
]);
// Plain-text MIME types where inlining the content into a text block is more
// useful than a "saved at <path>" stub for small files (under TEXT_INLINE_MAX).
export const TEXT_INLINE_MIME_SET = new Set([
"text/plain",
"text/markdown",
"text/csv",
"text/html",
"application/json",
"application/xml",
]);
export const TEXT_INLINE_MAX_BYTES = 8 * 1024;
export function mimeFromExtension(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
return EXTENSION_MIME[ext] ?? "application/octet-stream";
}

View File

@@ -0,0 +1,584 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawPluginNodeInvokePolicyContext } from "openclaw/plugin-sdk/plugin-entry";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createFileTransferNodeInvokePolicy } from "./node-invoke-policy.js";
vi.mock("./audit.js", () => ({
appendFileTransferAudit: vi.fn(async () => undefined),
}));
vi.mock("./policy.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./policy.js")>();
return {
...actual,
persistAllowAlways: vi.fn(async () => undefined),
};
});
const tmpRoots: string[] = [];
const testUnlessWindows = process.platform === "win32" ? it.skip : it;
afterEach(async () => {
await Promise.all(tmpRoots.map((tmpRoot) => fs.rm(tmpRoot, { recursive: true, force: true })));
tmpRoots.length = 0;
});
async function tarEntries(entries: Record<string, string>): Promise<string> {
const tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "node-policy-tar-")));
tmpRoots.push(tmpRoot);
for (const [relPath, contents] of Object.entries(entries)) {
const absPath = path.join(tmpRoot, relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, contents);
}
return await new Promise<string>((resolve, reject) => {
const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
const child = spawn(tarBin, ["-czf", "-", "-C", tmpRoot, "."], {
stdio: ["ignore", "pipe", "pipe"],
});
const chunks: Buffer[] = [];
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("close", (code) => {
if (code !== 0) {
reject(new Error(`tar exited ${code}: ${stderr}`));
return;
}
resolve(Buffer.concat(chunks).toString("base64"));
});
child.on("error", reject);
});
}
function createCtx(overrides: {
command?: string;
params?: Record<string, unknown>;
pluginConfig?: Record<string, unknown>;
approvals?: OpenClawPluginNodeInvokePolicyContext["approvals"];
}) {
const invokeNode = vi.fn<OpenClawPluginNodeInvokePolicyContext["invokeNode"]>(
async ({
params,
}: Parameters<OpenClawPluginNodeInvokePolicyContext["invokeNode"]>[0] = {}) => ({
ok: true,
payload: {
ok: true,
path:
typeof (params as { path?: unknown } | undefined)?.path === "string"
? (params as { path: string }).path
: "/tmp/file.txt",
size: 1,
sha256: "a".repeat(64),
},
}),
);
return {
ctx: {
nodeId: "node-1",
command: overrides.command ?? "file.fetch",
params: overrides.params ?? { path: "/tmp/file.txt", maxBytes: 1024 },
config: {},
pluginConfig: overrides.pluginConfig ?? {
nodes: {
"node-1": {
allowReadPaths: ["/tmp/**"],
allowWritePaths: ["/tmp/**"],
maxBytes: 512,
},
},
},
node: { nodeId: "node-1", displayName: "Node One" },
...(overrides.approvals ? { approvals: overrides.approvals } : {}),
invokeNode,
},
invokeNode,
};
}
describe("file-transfer node invoke policy", () => {
it("injects policy-owned limits before invoking the node", async () => {
const policy = createFileTransferNodeInvokePolicy();
const { ctx, invokeNode } = createCtx({
command: "file.fetch",
params: { path: "/tmp/file.txt", maxBytes: 4096, followSymlinks: true },
});
const result = await policy.handle(ctx);
expect(result.ok).toBe(true);
expect(invokeNode).toHaveBeenNthCalledWith(1, {
params: {
path: "/tmp/file.txt",
maxBytes: 512,
followSymlinks: false,
preflightOnly: true,
},
});
expect(invokeNode).toHaveBeenNthCalledWith(2, {
params: {
path: "/tmp/file.txt",
maxBytes: 512,
followSymlinks: false,
},
});
});
it("denies raw node.invoke before the node when plugin policy is missing", async () => {
const policy = createFileTransferNodeInvokePolicy();
const { ctx, invokeNode } = createCtx({ pluginConfig: {} });
const result = await policy.handle(ctx);
expect(result).toMatchObject({ ok: false, code: "NO_POLICY" });
expect(invokeNode).not.toHaveBeenCalled();
});
it("uses plugin approvals for ask-on-miss before invoking the node", async () => {
const policy = createFileTransferNodeInvokePolicy();
const approvals = {
request: vi.fn(async () => ({ id: "approval-1", decision: "allow-once" as const })),
};
const { ctx, invokeNode } = createCtx({
params: { path: "/tmp/new.txt" },
pluginConfig: {
nodes: {
"node-1": {
ask: "on-miss",
allowReadPaths: ["/allowed/**"],
maxBytes: 256,
},
},
},
approvals,
});
const result = await policy.handle(ctx);
expect(result.ok).toBe(true);
expect(approvals.request).toHaveBeenCalledWith(
expect.objectContaining({
title: "Read file: /tmp/new.txt",
severity: "info",
toolName: "file.fetch",
}),
);
expect(invokeNode).toHaveBeenNthCalledWith(1, {
params: {
path: "/tmp/new.txt",
followSymlinks: false,
maxBytes: 256,
preflightOnly: true,
},
});
expect(invokeNode).toHaveBeenNthCalledWith(2, {
params: {
path: "/tmp/new.txt",
followSymlinks: false,
maxBytes: 256,
},
});
});
it("marks node transport failures as unavailable", async () => {
const policy = createFileTransferNodeInvokePolicy();
const { ctx, invokeNode } = createCtx({
params: { path: "/tmp/file.txt" },
});
invokeNode.mockResolvedValueOnce({
ok: false,
code: "TIMEOUT",
message: "node timed out",
details: { nodeError: { code: "TIMEOUT" } },
});
const result = await policy.handle(ctx);
expect(result).toMatchObject({
ok: false,
code: "TIMEOUT",
unavailable: true,
details: { nodeError: { code: "TIMEOUT" } },
});
});
it("checks file.fetch canonical policy before requesting bytes", async () => {
const policy = createFileTransferNodeInvokePolicy();
const { ctx, invokeNode } = createCtx({
params: { path: "/tmp/link.txt" },
});
invokeNode.mockResolvedValueOnce({
ok: true,
payload: {
ok: true,
path: "/etc/passwd",
size: 1,
sha256: "a".repeat(64),
},
});
const result = await policy.handle(ctx);
expect(result).toMatchObject({ ok: false, code: "SYMLINK_TARGET_DENIED" });
expect(invokeNode).toHaveBeenCalledTimes(1);
expect(invokeNode).toHaveBeenCalledWith({
params: expect.objectContaining({
path: "/tmp/link.txt",
followSymlinks: false,
preflightOnly: true,
}),
});
});
it("continues file.fetch after preflight without forwarding caller preflightOnly", async () => {
const policy = createFileTransferNodeInvokePolicy();
const { ctx, invokeNode } = createCtx({
params: { path: "/tmp/file.txt", preflightOnly: true },
});
const result = await policy.handle(ctx);
expect(result).toMatchObject({ ok: true });
expect(invokeNode).toHaveBeenCalledTimes(2);
expect(invokeNode).toHaveBeenNthCalledWith(1, {
params: expect.objectContaining({ path: "/tmp/file.txt", preflightOnly: true }),
});
expect(invokeNode).toHaveBeenNthCalledWith(2, {
params: expect.not.objectContaining({ preflightOnly: true }),
});
});
it("checks file.write canonical policy before the mutating node call", async () => {
const policy = createFileTransferNodeInvokePolicy();
const { ctx, invokeNode } = createCtx({
command: "file.write",
params: {
path: "/tmp/link/out.txt",
contentBase64: Buffer.from("payload").toString("base64"),
createParents: true,
},
pluginConfig: {
nodes: {
"node-1": {
allowWritePaths: ["/tmp/**"],
followSymlinks: true,
},
},
},
});
invokeNode.mockResolvedValueOnce({
ok: true,
payload: {
ok: true,
path: "/etc/out.txt",
size: 7,
sha256: "b".repeat(64),
overwritten: false,
},
});
const result = await policy.handle(ctx);
expect(result).toMatchObject({ ok: false, code: "SYMLINK_TARGET_DENIED" });
expect(invokeNode).toHaveBeenCalledTimes(1);
expect(invokeNode).toHaveBeenCalledWith({
params: expect.objectContaining({
path: "/tmp/link/out.txt",
followSymlinks: true,
preflightOnly: true,
}),
});
});
it("continues file.write after preflight without forwarding caller preflightOnly", async () => {
const policy = createFileTransferNodeInvokePolicy();
const { ctx, invokeNode } = createCtx({
command: "file.write",
params: {
path: "/tmp/link/out.txt",
contentBase64: Buffer.from("payload").toString("base64"),
createParents: true,
preflightOnly: true,
},
pluginConfig: {
nodes: {
"node-1": {
allowWritePaths: ["/tmp/**", "/private/tmp/**"],
followSymlinks: true,
},
},
},
});
invokeNode
.mockResolvedValueOnce({
ok: true,
payload: {
ok: true,
path: "/private/tmp/out.txt",
size: 7,
sha256: "b".repeat(64),
overwritten: false,
},
})
.mockResolvedValueOnce({
ok: true,
payload: {
ok: true,
path: "/private/tmp/out.txt",
size: 7,
sha256: "b".repeat(64),
overwritten: false,
},
});
const result = await policy.handle(ctx);
expect(result).toMatchObject({ ok: true });
expect(invokeNode).toHaveBeenCalledTimes(2);
expect(invokeNode).toHaveBeenNthCalledWith(1, {
params: expect.objectContaining({ preflightOnly: true }),
});
expect(invokeNode).toHaveBeenNthCalledWith(2, {
params: expect.not.objectContaining({ preflightOnly: true }),
});
});
it("checks every dir.fetch preflight entry before requesting the archive", async () => {
const policy = createFileTransferNodeInvokePolicy();
const { ctx, invokeNode } = createCtx({
command: "dir.fetch",
params: { path: "/home/me" },
pluginConfig: {
nodes: {
"node-1": {
allowReadPaths: ["/home/me", "/home/me/**"],
denyPaths: ["**/.ssh/**"],
},
},
},
});
invokeNode.mockResolvedValueOnce({
ok: true,
payload: {
ok: true,
path: "/home/me",
entries: ["ok.txt", ".ssh/id_rsa"],
fileCount: 2,
preflightOnly: true,
},
});
const result = await policy.handle(ctx);
expect(result).toMatchObject({
ok: false,
code: "PATH_POLICY_DENIED",
details: { path: "/home/me/.ssh/id_rsa" },
});
expect(invokeNode).toHaveBeenCalledTimes(1);
expect(invokeNode).toHaveBeenCalledWith({
params: expect.objectContaining({ path: "/home/me", preflightOnly: true }),
});
});
it("rejects dir.fetch preflight responses without an entry list", async () => {
const policy = createFileTransferNodeInvokePolicy();
const { ctx, invokeNode } = createCtx({
command: "dir.fetch",
params: { path: "/home/me" },
pluginConfig: {
nodes: {
"node-1": {
allowReadPaths: ["/home/me", "/home/me/**"],
},
},
},
});
invokeNode.mockResolvedValueOnce({
ok: true,
payload: {
ok: true,
path: "/home/me",
fileCount: 2,
preflightOnly: true,
},
});
const result = await policy.handle(ctx);
expect(result).toMatchObject({ ok: false, code: "PREFLIGHT_ENTRIES_MISSING" });
expect(invokeNode).toHaveBeenCalledTimes(1);
});
it("rejects invalid dir.fetch preflight entries before requesting the archive", async () => {
const policy = createFileTransferNodeInvokePolicy();
const { ctx, invokeNode } = createCtx({
command: "dir.fetch",
params: { path: "/home/me" },
pluginConfig: {
nodes: {
"node-1": {
allowReadPaths: ["/home/me", "/home/me/**"],
},
},
},
});
invokeNode.mockResolvedValueOnce({
ok: true,
payload: {
ok: true,
path: "/home/me",
entries: ["ok.txt", "/etc/passwd"],
fileCount: 2,
preflightOnly: true,
},
});
const result = await policy.handle(ctx);
expect(result).toMatchObject({ ok: false, code: "PREFLIGHT_ENTRY_INVALID" });
expect(invokeNode).toHaveBeenCalledTimes(1);
});
testUnlessWindows(
"continues dir.fetch after preflight without forwarding caller preflightOnly",
async () => {
const policy = createFileTransferNodeInvokePolicy();
const tarBase64 = await tarEntries({
"a.txt": "a",
"sub/b.txt": "b",
});
const { ctx, invokeNode } = createCtx({
command: "dir.fetch",
params: { path: "/tmp/project", preflightOnly: true },
});
invokeNode
.mockResolvedValueOnce({
ok: true,
payload: {
ok: true,
path: "/tmp/project",
entries: ["a.txt", "sub/b.txt"],
fileCount: 2,
preflightOnly: true,
},
})
.mockResolvedValueOnce({
ok: true,
payload: {
ok: true,
path: "/tmp/project",
tarBase64,
tarBytes: 7,
sha256: "c".repeat(64),
fileCount: 2,
entries: ["a.txt", "sub/b.txt"],
},
});
const result = await policy.handle(ctx);
expect(result).toMatchObject({ ok: true });
expect(invokeNode).toHaveBeenCalledTimes(2);
expect(invokeNode).toHaveBeenNthCalledWith(1, {
params: expect.objectContaining({ path: "/tmp/project", preflightOnly: true }),
});
expect(invokeNode).toHaveBeenNthCalledWith(2, {
params: expect.not.objectContaining({ preflightOnly: true }),
});
},
);
testUnlessWindows(
"checks final dir.fetch archive entries before returning the archive",
async () => {
const policy = createFileTransferNodeInvokePolicy();
const tarBase64 = await tarEntries({
"ok.txt": "ok",
".ssh/id_rsa": "secret",
});
const { ctx, invokeNode } = createCtx({
command: "dir.fetch",
params: { path: "/home/me" },
pluginConfig: {
nodes: {
"node-1": {
allowReadPaths: ["/home/me", "/home/me/**"],
denyPaths: ["**/.ssh/**"],
},
},
},
});
invokeNode
.mockResolvedValueOnce({
ok: true,
payload: {
ok: true,
path: "/home/me",
entries: ["ok.txt"],
fileCount: 1,
preflightOnly: true,
},
})
.mockResolvedValueOnce({
ok: true,
payload: {
ok: true,
path: "/home/me",
tarBase64,
tarBytes: 7,
sha256: "c".repeat(64),
fileCount: 2,
},
});
const result = await policy.handle(ctx);
expect(result).toMatchObject({
ok: false,
code: "PATH_POLICY_DENIED",
details: { path: "/home/me/.ssh/id_rsa" },
});
expect(invokeNode).toHaveBeenCalledTimes(2);
},
);
it("rejects final dir.fetch archive responses without readable archive entries", async () => {
const policy = createFileTransferNodeInvokePolicy();
const { ctx, invokeNode } = createCtx({
command: "dir.fetch",
params: { path: "/tmp/project" },
});
invokeNode
.mockResolvedValueOnce({
ok: true,
payload: {
ok: true,
path: "/tmp/project",
entries: ["a.txt"],
fileCount: 1,
preflightOnly: true,
},
})
.mockResolvedValueOnce({
ok: true,
payload: {
ok: true,
path: "/tmp/project",
tarBytes: 7,
sha256: "c".repeat(64),
fileCount: 1,
},
});
const result = await policy.handle(ctx);
expect(result).toMatchObject({ ok: false, code: "ARCHIVE_ENTRIES_MISSING" });
expect(invokeNode).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,938 @@
import { spawn } from "node:child_process";
import type {
OpenClawPluginNodeInvokePolicy,
OpenClawPluginNodeInvokePolicyContext,
OpenClawPluginNodeInvokePolicyResult,
} from "openclaw/plugin-sdk/plugin-entry";
import { appendFileTransferAudit, type FileTransferAuditOp } from "./audit.js";
import { evaluateFilePolicy, persistAllowAlways, type FilePolicyKind } from "./policy.js";
const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
const DIR_FETCH_ARCHIVE_LIST_TIMEOUT_MS = 30_000;
const DIR_FETCH_ARCHIVE_LIST_MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
type FileTransferCommand = "file.fetch" | "dir.list" | "dir.fetch" | "file.write";
const COMMANDS: FileTransferCommand[] = ["file.fetch", "dir.list", "dir.fetch", "file.write"];
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readPath(params: Record<string, unknown>): string {
return typeof params.path === "string" ? params.path.trim() : "";
}
function readMaxBytes(input: {
value: unknown;
defaultValue: number;
hardMax: number;
policyMax?: number;
}): number {
const requested =
typeof input.value === "number" && Number.isFinite(input.value)
? Math.floor(input.value)
: input.defaultValue;
const clamped = Math.max(1, Math.min(requested, input.hardMax));
return input.policyMax ? Math.min(clamped, input.policyMax) : clamped;
}
function commandKind(command: FileTransferCommand): FilePolicyKind {
return command === "file.write" ? "write" : "read";
}
function promptVerb(command: FileTransferCommand): string {
switch (command) {
case "dir.fetch":
return "Fetch directory";
case "dir.list":
return "List directory";
case "file.write":
return "Write file";
case "file.fetch":
return "Read file";
}
return command;
}
async function requestApproval(input: {
ctx: OpenClawPluginNodeInvokePolicyContext;
op: FileTransferAuditOp;
kind: FilePolicyKind;
path: string;
startedAt: number;
}): Promise<
| { ok: true; followSymlinks: boolean; maxBytes?: number }
| { ok: false; message: string; code: string }
> {
const nodeDisplayName = input.ctx.node?.displayName;
const decision = evaluateFilePolicy({
nodeId: input.ctx.nodeId,
nodeDisplayName,
kind: input.kind,
path: input.path,
pluginConfig: input.ctx.pluginConfig,
});
if (decision.ok && decision.reason === "matched-allow") {
return {
ok: true,
followSymlinks: decision.followSymlinks,
maxBytes: decision.maxBytes,
};
}
const shouldAsk =
(decision.ok && decision.reason === "ask-always") || (!decision.ok && decision.askable);
if (!shouldAsk) {
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.path,
decision:
!decision.ok && decision.code === "NO_POLICY" ? "denied:no_policy" : "denied:policy",
errorCode: decision.ok ? undefined : decision.code,
reason: decision.ok ? decision.reason : decision.reason,
durationMs: Date.now() - input.startedAt,
});
return {
ok: false,
code: decision.ok ? "POLICY_DENIED" : decision.code,
message: `${input.op} ${decision.ok ? "POLICY_DENIED" : decision.code}: ${decision.reason}`,
};
}
const approvals = input.ctx.approvals;
if (!approvals) {
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.path,
decision: "denied:approval",
reason: "plugin approvals unavailable",
durationMs: Date.now() - input.startedAt,
});
return {
ok: false,
code: "APPROVAL_UNAVAILABLE",
message: `${input.op} APPROVAL_UNAVAILABLE: plugin approvals unavailable`,
};
}
const verb = promptVerb(input.op);
const subject = nodeDisplayName ?? input.ctx.nodeId;
const approval = await approvals.request({
title: `${verb}: ${input.path}`,
description: `Allow ${verb.toLowerCase()} on ${subject}\nPath: ${input.path}\nKind: ${input.kind}\n\n"allow-always" appends this exact path to allow${input.kind === "read" ? "Read" : "Write"}Paths.`,
severity: input.kind === "write" ? "warning" : "info",
toolName: input.op,
});
if (approval.decision === "deny" || approval.decision === null || !approval.decision) {
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.path,
decision: "denied:approval",
reason: approval.decision === "deny" ? "operator denied" : "no operator available",
durationMs: Date.now() - input.startedAt,
});
return {
ok: false,
code: approval.decision === "deny" ? "APPROVAL_DENIED" : "APPROVAL_UNAVAILABLE",
message:
approval.decision === "deny"
? `${input.op} APPROVAL_DENIED: operator denied the prompt`
: `${input.op} APPROVAL_UNAVAILABLE: no operator client connected to approve the request`,
};
}
if (approval.decision === "allow-always") {
try {
await persistAllowAlways({
nodeId: input.ctx.nodeId,
nodeDisplayName,
kind: input.kind,
path: input.path,
});
const refreshed = evaluateFilePolicy({
nodeId: input.ctx.nodeId,
nodeDisplayName,
kind: input.kind,
path: input.path,
pluginConfig: input.ctx.pluginConfig,
});
if (refreshed.ok) {
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.path,
decision: "allowed:always",
durationMs: Date.now() - input.startedAt,
});
return {
ok: true,
followSymlinks: refreshed.followSymlinks,
maxBytes: refreshed.maxBytes,
};
}
} catch (error) {
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.path,
decision: "allowed:always",
reason: `persist failed: ${String(error)}`,
durationMs: Date.now() - input.startedAt,
});
return {
ok: true,
followSymlinks: decision.ok ? decision.followSymlinks : false,
maxBytes: decision.maxBytes,
};
}
}
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.path,
decision: approval.decision === "allow-always" ? "allowed:always" : "allowed:once",
durationMs: Date.now() - input.startedAt,
});
return {
ok: true,
followSymlinks: decision.ok ? decision.followSymlinks : false,
maxBytes: decision.maxBytes,
};
}
function prepareParams(input: {
command: FileTransferCommand;
params: Record<string, unknown>;
followSymlinks: boolean;
maxBytes?: number;
}): Record<string, unknown> {
const next: Record<string, unknown> = {
...input.params,
followSymlinks: input.followSymlinks,
};
delete next.preflightOnly;
if (input.command === "file.fetch") {
next.maxBytes = readMaxBytes({
value: input.params.maxBytes,
defaultValue: FILE_FETCH_DEFAULT_MAX_BYTES,
hardMax: FILE_FETCH_HARD_MAX_BYTES,
policyMax: input.maxBytes,
});
} else if (input.command === "dir.fetch") {
next.maxBytes = readMaxBytes({
value: input.params.maxBytes,
defaultValue: DIR_FETCH_DEFAULT_MAX_BYTES,
hardMax: DIR_FETCH_HARD_MAX_BYTES,
policyMax: input.maxBytes,
});
}
return next;
}
function readResultPayload(result: { payload?: unknown }): Record<string, unknown> | null {
return result.payload && typeof result.payload === "object" && !Array.isArray(result.payload)
? (result.payload as Record<string, unknown>)
: null;
}
function joinRemotePolicyPath(root: string, relPath: string): string {
const rel = relPath.replace(/\\/gu, "/").replace(/^\.\//u, "");
if (!rel || rel === ".") {
return root;
}
const sep = root.includes("\\") && !root.includes("/") ? "\\" : "/";
const cleanRoot = root.replace(/[\\/]$/u, "");
const prefix = cleanRoot || sep;
return `${prefix}${prefix.endsWith(sep) ? "" : sep}${rel.split("/").join(sep)}`;
}
function validateDirFetchPreflightEntry(
entry: string,
): { ok: true } | { ok: false; reason: string } {
if (entry.includes("\0")) {
return { ok: false, reason: "entry contains NUL byte" };
}
const normalized = entry.replace(/\\/gu, "/").replace(/^\.\//u, "");
if (!normalized || normalized === ".") {
return { ok: false, reason: "entry is empty" };
}
if (normalized.startsWith("/") || /^[A-Za-z]:\//u.test(normalized)) {
return { ok: false, reason: "entry is absolute" };
}
if (normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
return { ok: false, reason: "entry contains '..' traversal" };
}
return { ok: true };
}
function normalizeTarEntryPath(entry: string): string | null {
const normalized = entry.replace(/\\/gu, "/").replace(/^\.\//u, "").replace(/\/$/u, "");
return normalized.length > 0 ? normalized : null;
}
async function listDirFetchArchiveEntries(
payload: Record<string, unknown> | null,
): Promise<{ ok: true; entries: string[] } | { ok: false; code: string; reason: string }> {
const tarBase64 = typeof payload?.tarBase64 === "string" ? payload.tarBase64 : "";
if (!tarBase64) {
return {
ok: false,
code: "ARCHIVE_ENTRIES_MISSING",
reason: "dir.fetch archive did not return tarBase64",
};
}
const tarBuffer = Buffer.from(tarBase64, "base64");
return await new Promise<
{ ok: true; entries: string[] } | { ok: false; code: string; reason: string }
>((resolve) => {
const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
const child = spawn(tarBin, ["-tzf", "-"], { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
let aborted = false;
const watchdog = setTimeout(() => {
aborted = true;
try {
child.kill("SIGKILL");
} catch {
/* gone */
}
resolve({
ok: false,
code: "ARCHIVE_ENTRIES_UNREADABLE",
reason: "tar -tzf timed out",
});
}, DIR_FETCH_ARCHIVE_LIST_TIMEOUT_MS);
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
if (stdout.length > DIR_FETCH_ARCHIVE_LIST_MAX_OUTPUT_BYTES) {
aborted = true;
clearTimeout(watchdog);
try {
child.kill("SIGKILL");
} catch {
/* gone */
}
resolve({
ok: false,
code: "ARCHIVE_ENTRIES_UNREADABLE",
reason: "tar -tzf output too large",
});
}
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("close", (code) => {
clearTimeout(watchdog);
if (aborted) {
return;
}
if (code !== 0) {
resolve({
ok: false,
code: "ARCHIVE_ENTRIES_UNREADABLE",
reason: `tar -tzf exited ${code}: ${stderr.slice(0, 200)}`,
});
return;
}
resolve({
ok: true,
entries: stdout
.split("\n")
.map(normalizeTarEntryPath)
.filter((entry): entry is string => entry !== null),
});
});
child.on("error", (error) => {
clearTimeout(watchdog);
if (!aborted) {
resolve({
ok: false,
code: "ARCHIVE_ENTRIES_UNREADABLE",
reason: `tar -tzf error: ${String(error)}`,
});
}
});
child.stdin.end(tarBuffer);
});
}
async function validateDirFetchEntries(input: {
ctx: OpenClawPluginNodeInvokePolicyContext;
op: FileTransferAuditOp;
requestedPath: string;
canonicalPath: string;
entries: unknown;
startedAt: number;
phase: "preflight" | "archive";
}): Promise<OpenClawPluginNodeInvokePolicyResult | null> {
const nodeDisplayName = input.ctx.node?.displayName;
const missingCode =
input.phase === "preflight" ? "PREFLIGHT_ENTRIES_MISSING" : "ARCHIVE_ENTRIES_MISSING";
const invalidCode =
input.phase === "preflight" ? "PREFLIGHT_ENTRY_INVALID" : "ARCHIVE_ENTRY_INVALID";
if (!Array.isArray(input.entries)) {
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.requestedPath,
canonicalPath: input.canonicalPath,
decision: "error",
errorCode: missingCode,
reason: `dir.fetch ${input.phase} did not return entries`,
durationMs: Date.now() - input.startedAt,
});
return policyDeniedResult({
op: input.op,
code: missingCode,
message: `dir.fetch ${input.phase} did not return entries; refusing archive transfer`,
details: { path: input.canonicalPath },
});
}
const entries: string[] = [];
for (const entry of input.entries) {
if (typeof entry !== "string" || entry.length === 0) {
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.requestedPath,
canonicalPath: input.canonicalPath,
decision: "denied:policy",
errorCode: invalidCode,
reason: "entry is not a non-empty string",
durationMs: Date.now() - input.startedAt,
});
return policyDeniedResult({
op: input.op,
code: invalidCode,
message: `directory ${input.phase} entry is invalid: entry is not a non-empty string`,
details: { path: input.canonicalPath, reason: "entry is not a non-empty string" },
});
}
const entryValidation = validateDirFetchPreflightEntry(entry);
if (!entryValidation.ok) {
const candidate = joinRemotePolicyPath(input.canonicalPath, entry);
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.requestedPath,
canonicalPath: candidate,
decision: "denied:policy",
errorCode: invalidCode,
reason: entryValidation.reason,
durationMs: Date.now() - input.startedAt,
});
return policyDeniedResult({
op: input.op,
code: invalidCode,
message: `directory ${input.phase} entry ${entry} is invalid: ${entryValidation.reason}`,
details: { path: candidate, reason: entryValidation.reason },
});
}
entries.push(entry);
}
const candidates = [
input.canonicalPath,
...entries.map((entry) => joinRemotePolicyPath(input.canonicalPath, entry)),
];
for (const candidate of candidates) {
const policy = evaluateFilePolicy({
nodeId: input.ctx.nodeId,
nodeDisplayName,
kind: "read",
path: candidate,
pluginConfig: input.ctx.pluginConfig,
});
if (policy.ok) {
continue;
}
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.requestedPath,
canonicalPath: candidate,
decision: "denied:policy",
errorCode: policy.code,
reason: policy.reason,
durationMs: Date.now() - input.startedAt,
});
return policyDeniedResult({
op: input.op,
code: "PATH_POLICY_DENIED",
message: `directory ${input.phase} entry ${candidate} is not allowed by policy: ${policy.reason}`,
details: { path: candidate, reason: policy.reason },
});
}
return null;
}
function policyDeniedResult(input: {
op: FileTransferAuditOp;
code: string;
message: string;
details?: Record<string, unknown>;
}): OpenClawPluginNodeInvokePolicyResult {
return {
ok: false,
code: input.code,
message: `${input.op} ${input.code}: ${input.message}`,
...(input.details ? { details: input.details } : {}),
};
}
async function runWritePreflight(input: {
ctx: OpenClawPluginNodeInvokePolicyContext;
op: FileTransferAuditOp;
params: Record<string, unknown>;
requestedPath: string;
startedAt: number;
}): Promise<OpenClawPluginNodeInvokePolicyResult | null> {
const nodeDisplayName = input.ctx.node?.displayName;
const preflight = await input.ctx.invokeNode({
params: {
...input.params,
preflightOnly: true,
},
});
if (!preflight.ok) {
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.requestedPath,
decision: "error",
errorCode: preflight.code,
errorMessage: preflight.message,
durationMs: Date.now() - input.startedAt,
});
return {
ok: false,
code: preflight.code,
message: `${input.op} failed: ${preflight.message}`,
details: preflight.details,
unavailable: true,
};
}
const payload = readResultPayload(preflight);
if (payload?.ok === false) {
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.requestedPath,
canonicalPath: typeof payload.canonicalPath === "string" ? payload.canonicalPath : undefined,
decision: "error",
errorCode: typeof payload.code === "string" ? payload.code : undefined,
errorMessage: typeof payload.message === "string" ? payload.message : undefined,
durationMs: Date.now() - input.startedAt,
});
return preflight;
}
const canonicalPath =
payload && typeof payload.path === "string" && payload.path
? payload.path
: input.requestedPath;
if (canonicalPath === input.requestedPath) {
return null;
}
const policy = evaluateFilePolicy({
nodeId: input.ctx.nodeId,
nodeDisplayName,
kind: "write",
path: canonicalPath,
pluginConfig: input.ctx.pluginConfig,
});
if (policy.ok) {
return null;
}
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.requestedPath,
canonicalPath,
decision: "denied:symlink_escape",
errorCode: policy.code,
reason: policy.reason,
durationMs: Date.now() - input.startedAt,
});
return {
ok: false,
code: "SYMLINK_TARGET_DENIED",
message: `${input.op} SYMLINK_TARGET_DENIED: requested path resolved to ${canonicalPath} which is not allowed by policy`,
};
}
async function runFileFetchPreflight(input: {
ctx: OpenClawPluginNodeInvokePolicyContext;
op: FileTransferAuditOp;
params: Record<string, unknown>;
requestedPath: string;
startedAt: number;
}): Promise<OpenClawPluginNodeInvokePolicyResult | null> {
const nodeDisplayName = input.ctx.node?.displayName;
const preflight = await input.ctx.invokeNode({
params: {
...input.params,
preflightOnly: true,
},
});
if (!preflight.ok) {
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.requestedPath,
decision: "error",
errorCode: preflight.code,
errorMessage: preflight.message,
durationMs: Date.now() - input.startedAt,
});
return {
ok: false,
code: preflight.code,
message: `${input.op} failed: ${preflight.message}`,
details: preflight.details,
unavailable: true,
};
}
const payload = readResultPayload(preflight);
if (payload?.ok === false) {
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.requestedPath,
canonicalPath: typeof payload.canonicalPath === "string" ? payload.canonicalPath : undefined,
decision: "error",
errorCode: typeof payload.code === "string" ? payload.code : undefined,
errorMessage: typeof payload.message === "string" ? payload.message : undefined,
durationMs: Date.now() - input.startedAt,
});
return preflight;
}
const canonicalPath =
payload && typeof payload.path === "string" && payload.path
? payload.path
: input.requestedPath;
if (canonicalPath === input.requestedPath) {
return null;
}
const policy = evaluateFilePolicy({
nodeId: input.ctx.nodeId,
nodeDisplayName,
kind: "read",
path: canonicalPath,
pluginConfig: input.ctx.pluginConfig,
});
if (policy.ok) {
return null;
}
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.requestedPath,
canonicalPath,
decision: "denied:symlink_escape",
errorCode: policy.code,
reason: policy.reason,
durationMs: Date.now() - input.startedAt,
});
return {
ok: false,
code: "SYMLINK_TARGET_DENIED",
message: `${input.op} SYMLINK_TARGET_DENIED: requested path resolved to ${canonicalPath} which is not allowed by policy`,
};
}
async function runDirFetchPreflight(input: {
ctx: OpenClawPluginNodeInvokePolicyContext;
op: FileTransferAuditOp;
params: Record<string, unknown>;
requestedPath: string;
startedAt: number;
}): Promise<OpenClawPluginNodeInvokePolicyResult | null> {
const nodeDisplayName = input.ctx.node?.displayName;
const preflight = await input.ctx.invokeNode({
params: {
...input.params,
preflightOnly: true,
},
});
if (!preflight.ok) {
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.requestedPath,
decision: "error",
errorCode: preflight.code,
errorMessage: preflight.message,
durationMs: Date.now() - input.startedAt,
});
return {
ok: false,
code: preflight.code,
message: `${input.op} failed: ${preflight.message}`,
details: preflight.details,
unavailable: true,
};
}
const payload = readResultPayload(preflight);
if (payload?.ok === false) {
await appendFileTransferAudit({
op: input.op,
nodeId: input.ctx.nodeId,
nodeDisplayName,
requestedPath: input.requestedPath,
canonicalPath: typeof payload.canonicalPath === "string" ? payload.canonicalPath : undefined,
decision: "error",
errorCode: typeof payload.code === "string" ? payload.code : undefined,
errorMessage: typeof payload.message === "string" ? payload.message : undefined,
durationMs: Date.now() - input.startedAt,
});
return preflight;
}
const canonicalPath =
payload && typeof payload.path === "string" && payload.path
? payload.path
: input.requestedPath;
return await validateDirFetchEntries({
ctx: input.ctx,
op: input.op,
requestedPath: input.requestedPath,
canonicalPath,
entries: payload?.entries,
startedAt: input.startedAt,
phase: "preflight",
});
}
async function handleFileTransferInvoke(
ctx: OpenClawPluginNodeInvokePolicyContext,
): Promise<OpenClawPluginNodeInvokePolicyResult> {
if (!COMMANDS.includes(ctx.command as FileTransferCommand)) {
return { ok: false, code: "UNSUPPORTED_COMMAND", message: "unsupported file-transfer command" };
}
const command = ctx.command as FileTransferCommand;
const op: FileTransferAuditOp = command;
const params = asRecord(ctx.params);
const requestedPath = readPath(params);
const nodeDisplayName = ctx.node?.displayName;
const startedAt = Date.now();
if (!requestedPath) {
return { ok: false, code: "INVALID_PARAMS", message: `${op} path required` };
}
const gate = await requestApproval({
ctx,
op,
kind: commandKind(command),
path: requestedPath,
startedAt,
});
if (!gate.ok) {
return { ok: false, code: gate.code, message: gate.message };
}
const forwardedParams = prepareParams({
command,
params,
followSymlinks: gate.followSymlinks,
maxBytes: gate.maxBytes,
});
if (command === "file.fetch") {
const preflightDeny = await runFileFetchPreflight({
ctx,
op,
params: forwardedParams,
requestedPath,
startedAt,
});
if (preflightDeny) {
return preflightDeny;
}
} else if (command === "file.write") {
const preflightDeny = await runWritePreflight({
ctx,
op,
params: forwardedParams,
requestedPath,
startedAt,
});
if (preflightDeny) {
return preflightDeny;
}
} else if (command === "dir.fetch") {
const preflightDeny = await runDirFetchPreflight({
ctx,
op,
params: forwardedParams,
requestedPath,
startedAt,
});
if (preflightDeny) {
return preflightDeny;
}
}
const result = await ctx.invokeNode({ params: forwardedParams });
if (!result.ok) {
await appendFileTransferAudit({
op,
nodeId: ctx.nodeId,
nodeDisplayName,
requestedPath,
decision: "error",
errorCode: result.code,
errorMessage: result.message,
durationMs: Date.now() - startedAt,
});
return {
ok: false,
code: result.code,
message: `${op} failed: ${result.message}`,
details: result.details,
unavailable: true,
};
}
const payload = readResultPayload(result);
if (payload?.ok === false) {
await appendFileTransferAudit({
op,
nodeId: ctx.nodeId,
nodeDisplayName,
requestedPath,
canonicalPath: typeof payload.canonicalPath === "string" ? payload.canonicalPath : undefined,
decision: "error",
errorCode: typeof payload.code === "string" ? payload.code : undefined,
errorMessage: typeof payload.message === "string" ? payload.message : undefined,
durationMs: Date.now() - startedAt,
});
return result;
}
const canonicalPath =
payload && typeof payload.path === "string" && payload.path ? payload.path : requestedPath;
if (canonicalPath !== requestedPath) {
const postflight = evaluateFilePolicy({
nodeId: ctx.nodeId,
nodeDisplayName,
kind: commandKind(command),
path: canonicalPath,
pluginConfig: ctx.pluginConfig,
});
if (!postflight.ok) {
await appendFileTransferAudit({
op,
nodeId: ctx.nodeId,
nodeDisplayName,
requestedPath,
canonicalPath,
decision: "denied:symlink_escape",
errorCode: postflight.code,
reason: postflight.reason,
durationMs: Date.now() - startedAt,
});
return {
ok: false,
code: "SYMLINK_TARGET_DENIED",
message: `${op} SYMLINK_TARGET_DENIED: requested path resolved to ${canonicalPath} which is not allowed by policy`,
};
}
}
if (command === "dir.fetch") {
const archiveEntries = await listDirFetchArchiveEntries(payload);
if (!archiveEntries.ok) {
await appendFileTransferAudit({
op,
nodeId: ctx.nodeId,
nodeDisplayName,
requestedPath,
canonicalPath,
decision: "error",
errorCode: archiveEntries.code,
reason: archiveEntries.reason,
durationMs: Date.now() - startedAt,
});
return policyDeniedResult({
op,
code: archiveEntries.code,
message: `${archiveEntries.reason}; refusing archive transfer`,
details: { path: canonicalPath, reason: archiveEntries.reason },
});
}
const archiveDeny = await validateDirFetchEntries({
ctx,
op,
requestedPath,
canonicalPath,
entries: archiveEntries.entries,
startedAt,
phase: "archive",
});
if (archiveDeny) {
return archiveDeny;
}
}
await appendFileTransferAudit({
op,
nodeId: ctx.nodeId,
nodeDisplayName,
requestedPath,
canonicalPath,
decision: "allowed",
sizeBytes: typeof payload?.size === "number" ? payload.size : undefined,
sha256: typeof payload?.sha256 === "string" ? payload.sha256 : undefined,
durationMs: Date.now() - startedAt,
});
return result;
}
export function createFileTransferNodeInvokePolicy(): OpenClawPluginNodeInvokePolicy {
return {
commands: COMMANDS,
handle: handleFileTransferInvoke,
};
}

View File

@@ -0,0 +1,62 @@
// Shared param-validation helpers used by all four agent tools.
// Goal: identical validation behavior + identical error shapes everywhere.
export type GatewayCallOptions = {
gatewayUrl?: string;
gatewayToken?: string;
timeoutMs?: number;
};
export function readGatewayCallOptions(params: Record<string, unknown>): GatewayCallOptions {
const opts: GatewayCallOptions = {};
if (typeof params.gatewayUrl === "string" && params.gatewayUrl.trim()) {
opts.gatewayUrl = params.gatewayUrl.trim();
}
if (typeof params.gatewayToken === "string" && params.gatewayToken.trim()) {
opts.gatewayToken = params.gatewayToken.trim();
}
if (typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)) {
opts.timeoutMs = params.timeoutMs;
}
return opts;
}
export function readTrimmedString(params: Record<string, unknown>, key: string): string {
const value = params[key];
return typeof value === "string" ? value.trim() : "";
}
export function readBoolean(
params: Record<string, unknown>,
key: string,
defaultValue = false,
): boolean {
const value = params[key];
if (typeof value === "boolean") {
return value;
}
return defaultValue;
}
export function readClampedInt(params: {
input: Record<string, unknown>;
key: string;
defaultValue: number;
hardMin: number;
hardMax: number;
}): number {
const value = params.input[params.key];
const requested =
typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : params.defaultValue;
return Math.max(params.hardMin, Math.min(requested, params.hardMax));
}
export function humanSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}

View File

@@ -0,0 +1,506 @@
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Mock the plugin-sdk runtime-config surface so we can drive the policy
// reader from the test without booting a gateway. mutateConfigFile is also
// mocked so persistAllowAlways tests can assert what would have been written
// without touching ~/.openclaw/openclaw.json.
const getRuntimeConfigMock = vi.fn();
const mutateConfigFileMock = vi.fn();
vi.mock("openclaw/plugin-sdk/runtime-config-snapshot", () => ({
getRuntimeConfig: () => getRuntimeConfigMock(),
}));
vi.mock("openclaw/plugin-sdk/config-mutation", () => ({
mutateConfigFile: (input: unknown) => mutateConfigFileMock(input),
}));
// Imported AFTER vi.mock so the mocked module is what policy.ts binds to.
const { evaluateFilePolicy, persistAllowAlways } = await import("./policy.js");
beforeEach(() => {
getRuntimeConfigMock.mockReset();
mutateConfigFileMock.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
function withConfig(fileTransfer: Record<string, unknown> | undefined) {
if (fileTransfer === undefined) {
getRuntimeConfigMock.mockReturnValue({});
} else {
getRuntimeConfigMock.mockReturnValue({
plugins: {
entries: {
"file-transfer": {
config: { nodes: fileTransfer },
},
},
},
});
}
}
describe("evaluateFilePolicy — default deny", () => {
it("returns NO_POLICY when no plugin config block is present", () => {
getRuntimeConfigMock.mockReturnValue({});
const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
expect(r).toMatchObject({ ok: false, code: "NO_POLICY", askable: false });
});
it("returns NO_POLICY when plugin policy block is missing", () => {
getRuntimeConfigMock.mockReturnValue({ plugins: { entries: { "file-transfer": {} } } });
const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
expect(r).toMatchObject({ ok: false, code: "NO_POLICY" });
});
it("returns NO_POLICY when no entry exists for the node and no '*' fallback", () => {
withConfig({ "other-node": { allowReadPaths: ["/tmp/**"] } });
const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
expect(r).toMatchObject({ ok: false, code: "NO_POLICY" });
});
it("prefers the current runtime config over a stale passed plugin config", () => {
getRuntimeConfigMock.mockReturnValue({
plugins: {
entries: {
"file-transfer": {
config: {
nodes: {
n1: { allowReadPaths: ["/tmp/**"] },
},
},
},
},
},
});
const r = evaluateFilePolicy({
nodeId: "n1",
kind: "read",
path: "/tmp/x",
pluginConfig: {
nodes: {
n1: { allowReadPaths: ["/stale/**"] },
},
},
});
expect(r).toMatchObject({ ok: true, reason: "matched-allow" });
});
});
describe("evaluateFilePolicy — '..' traversal short-circuit", () => {
it("rejects /allowed/../etc/passwd even when /allowed/** is allowed", () => {
withConfig({
n1: { allowReadPaths: ["/allowed/**"] },
});
const r = evaluateFilePolicy({
nodeId: "n1",
kind: "read",
path: "/allowed/../etc/passwd",
});
expect(r).toMatchObject({ ok: false, code: "POLICY_DENIED", askable: false });
expect(r.ok ? "" : r.reason).toMatch(/\.\./);
});
it("rejects a path that ENDS in /..", () => {
withConfig({
n1: { allowReadPaths: ["/tmp/**"] },
});
const r = evaluateFilePolicy({
nodeId: "n1",
kind: "read",
path: "/tmp/foo/..",
});
expect(r).toMatchObject({ ok: false, code: "POLICY_DENIED" });
});
it("rejects bare '..'", () => {
withConfig({
n1: { allowReadPaths: ["/**"] },
});
const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: ".." });
expect(r).toMatchObject({ ok: false, code: "POLICY_DENIED" });
});
});
describe("evaluateFilePolicy — denyPaths always wins", () => {
it("denies even when allowReadPaths matches", () => {
withConfig({
n1: {
allowReadPaths: ["/tmp/**"],
denyPaths: ["**/.ssh/**"],
},
});
const r = evaluateFilePolicy({
nodeId: "n1",
kind: "read",
path: "/tmp/.ssh/id_rsa",
});
expect(r).toMatchObject({ ok: false, code: "POLICY_DENIED", askable: false });
expect(r.ok ? "" : r.reason).toMatch(/deny/);
});
it("denies even with ask=always (denyPaths is hard)", () => {
withConfig({
n1: {
ask: "always",
denyPaths: ["**/secrets/**"],
},
});
const r = evaluateFilePolicy({
nodeId: "n1",
kind: "read",
path: "/var/secrets/api.key",
});
expect(r).toMatchObject({ ok: false, code: "POLICY_DENIED", askable: false });
});
});
describe("evaluateFilePolicy — allow matching", () => {
it("allows on matched-allow with ask=off (default)", () => {
withConfig({
n1: { allowReadPaths: ["/tmp/**"] },
});
expect(evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/foo/bar.png" })).toEqual({
ok: true,
reason: "matched-allow",
maxBytes: undefined,
followSymlinks: false,
});
});
it("propagates per-node maxBytes on matched-allow", () => {
withConfig({
n1: { allowReadPaths: ["/tmp/**"], maxBytes: 1024 },
});
const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
expect(r).toMatchObject({ ok: true, maxBytes: 1024 });
});
it("uses kind=write to consult allowWritePaths, not allowReadPaths", () => {
withConfig({
n1: { allowReadPaths: ["/tmp/**"], allowWritePaths: ["/srv/**"] },
});
expect(evaluateFilePolicy({ nodeId: "n1", kind: "write", path: "/srv/out.txt" })).toMatchObject(
{ ok: true },
);
expect(evaluateFilePolicy({ nodeId: "n1", kind: "write", path: "/tmp/out.txt" })).toMatchObject(
{ ok: false, code: "POLICY_DENIED" },
);
});
it("propagates followSymlinks=false by default and =true when configured", () => {
withConfig({
n1: { allowReadPaths: ["/tmp/**"] },
});
expect(evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" })).toMatchObject({
ok: true,
followSymlinks: false,
});
withConfig({
n2: { allowReadPaths: ["/tmp/**"], followSymlinks: true },
});
expect(evaluateFilePolicy({ nodeId: "n2", kind: "read", path: "/tmp/x" })).toMatchObject({
ok: true,
followSymlinks: true,
});
});
it("expands tilde in patterns relative to homedir", () => {
const home = os.homedir();
withConfig({
n1: { allowReadPaths: ["~/Screenshots/**"] },
});
expect(
evaluateFilePolicy({
nodeId: "n1",
kind: "read",
path: path.join(home, "Screenshots", "shot.png"),
}),
).toMatchObject({ ok: true });
});
it("matches Windows node paths without gateway-local path semantics", () => {
withConfig({
n1: { allowReadPaths: ["C:/Users/me/**"] },
});
expect(
evaluateFilePolicy({
nodeId: "n1",
kind: "read",
path: "C:\\Users\\me\\file.txt",
}),
).toMatchObject({ ok: true });
});
});
describe("evaluateFilePolicy — ask modes", () => {
it("ask=on-miss returns askable POLICY_DENIED on miss", () => {
withConfig({
n1: { ask: "on-miss", allowReadPaths: ["/var/log/**"] },
});
const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
expect(r).toMatchObject({
ok: false,
code: "POLICY_DENIED",
askable: true,
askMode: "on-miss",
});
});
it("ask=on-miss miss preserves transfer caps for one-time approvals", () => {
withConfig({
n1: {
ask: "on-miss",
allowReadPaths: ["/var/log/**"],
maxBytes: 4096,
followSymlinks: true,
},
});
const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
expect(r).toMatchObject({
ok: false,
code: "POLICY_DENIED",
askable: true,
askMode: "on-miss",
maxBytes: 4096,
followSymlinks: true,
});
});
it("ask=on-miss still silent-allows on a match", () => {
withConfig({
n1: { ask: "on-miss", allowReadPaths: ["/tmp/**"] },
});
const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
expect(r).toMatchObject({ ok: true, reason: "matched-allow" });
});
it("ask=always always returns ask-always (prompt on every call)", () => {
withConfig({
n1: { ask: "always", allowReadPaths: ["/tmp/**"] },
});
const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
expect(r).toMatchObject({ ok: true, reason: "ask-always", askMode: "always" });
});
it("ask=off returns non-askable POLICY_DENIED on miss", () => {
withConfig({
n1: { ask: "off", allowReadPaths: ["/var/log/**"] },
});
const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
expect(r).toMatchObject({ ok: false, code: "POLICY_DENIED", askable: false });
});
it("invalid ask values normalize to off", () => {
withConfig({
n1: { ask: "sometimes", allowReadPaths: ["/var/log/**"] },
});
const r = evaluateFilePolicy({ nodeId: "n1", kind: "read", path: "/tmp/x" });
expect(r).toMatchObject({ ok: false, askable: false });
});
});
describe("evaluateFilePolicy — node-id resolution", () => {
it("resolves by displayName when nodeId has no entry", () => {
withConfig({
"Lobster MacBook": { allowReadPaths: ["/tmp/**"] },
});
expect(
evaluateFilePolicy({
nodeId: "node-abc-123",
nodeDisplayName: "Lobster MacBook",
kind: "read",
path: "/tmp/x",
}),
).toMatchObject({ ok: true });
});
it("falls back to '*' wildcard when neither id nor displayName matches", () => {
withConfig({
"*": { allowReadPaths: ["/tmp/**"] },
});
expect(
evaluateFilePolicy({
nodeId: "n1",
nodeDisplayName: "anything",
kind: "read",
path: "/tmp/x",
}),
).toMatchObject({ ok: true });
});
});
describe("persistAllowAlways", () => {
it("appends path to allowReadPaths under the existing matching key", async () => {
let captured: Record<string, unknown> | null = null;
mutateConfigFileMock.mockImplementation(
async ({ mutate }: { mutate: (draft: Record<string, unknown>) => void }) => {
const draft: Record<string, unknown> = {
plugins: {
entries: {
"file-transfer": {
config: { nodes: { n1: { allowReadPaths: ["/tmp/**"] } } },
},
},
},
};
mutate(draft);
captured = draft;
},
);
await persistAllowAlways({ nodeId: "n1", kind: "read", path: "/srv/added.png" });
expect(mutateConfigFileMock).toHaveBeenCalledOnce();
// Drill back into the captured draft to assert the added path.
const root = captured as unknown as {
plugins: {
entries: {
"file-transfer": {
config: { nodes: Record<string, { allowReadPaths: string[] }> };
};
};
};
};
expect(root.plugins.entries["file-transfer"].config.nodes.n1.allowReadPaths).toContain(
"/srv/added.png",
);
});
it("creates a new node entry keyed by displayName when no entry exists", async () => {
let captured: Record<string, unknown> | null = null;
mutateConfigFileMock.mockImplementation(
async ({ mutate }: { mutate: (draft: Record<string, unknown>) => void }) => {
const draft: Record<string, unknown> = {};
mutate(draft);
captured = draft;
},
);
await persistAllowAlways({
nodeId: "n1",
nodeDisplayName: "Lobster",
kind: "write",
path: "/srv/out.txt",
});
const root = captured as unknown as {
plugins: {
entries: {
"file-transfer": {
config: { nodes: Record<string, { allowWritePaths: string[] }> };
};
};
};
};
expect(root.plugins.entries["file-transfer"].config.nodes["Lobster"].allowWritePaths).toContain(
"/srv/out.txt",
);
});
it("never persists under the '*' wildcard even when '*' is the matching key", async () => {
let captured: Record<string, unknown> | null = null;
mutateConfigFileMock.mockImplementation(
async ({ mutate }: { mutate: (draft: Record<string, unknown>) => void }) => {
const draft: Record<string, unknown> = {
plugins: {
entries: {
"file-transfer": {
config: { nodes: { "*": { allowReadPaths: ["/var/log/**"] } } },
},
},
},
};
mutate(draft);
captured = draft;
},
);
await persistAllowAlways({
nodeId: "n1",
nodeDisplayName: "Lobster",
kind: "read",
path: "/srv/added.png",
});
const root = captured as unknown as {
plugins: {
entries: {
"file-transfer": {
config: { nodes: Record<string, { allowReadPaths?: string[] }> };
};
};
};
};
// The "*" entry must not have been mutated.
expect(root.plugins.entries["file-transfer"].config.nodes["*"].allowReadPaths).toEqual([
"/var/log/**",
]);
// A new entry keyed by displayName (not "*") must hold the new path.
expect(root.plugins.entries["file-transfer"].config.nodes["Lobster"].allowReadPaths).toEqual([
"/srv/added.png",
]);
});
it("rejects unsafe keys (__proto__, prototype, constructor) that would mutate prototype chain", async () => {
mutateConfigFileMock.mockImplementation(
async ({ mutate }: { mutate: (draft: Record<string, unknown>) => void }) => {
const draft: Record<string, unknown> = {};
mutate(draft);
},
);
await expect(
persistAllowAlways({
nodeId: "n1",
nodeDisplayName: "__proto__",
kind: "read",
path: "/etc/passwd",
}),
).rejects.toThrow(/unsafe key.*__proto__/);
await expect(
persistAllowAlways({
nodeId: "constructor",
kind: "read",
path: "/etc/passwd",
}),
).rejects.toThrow(/unsafe key.*constructor/);
});
it("dedupes when path already present", async () => {
let captured: Record<string, unknown> | null = null;
mutateConfigFileMock.mockImplementation(
async ({ mutate }: { mutate: (draft: Record<string, unknown>) => void }) => {
const draft: Record<string, unknown> = {
plugins: {
entries: {
"file-transfer": {
config: { nodes: { n1: { allowReadPaths: ["/tmp/x"] } } },
},
},
},
};
mutate(draft);
captured = draft;
},
);
await persistAllowAlways({ nodeId: "n1", kind: "read", path: "/tmp/x" });
const root = captured as unknown as {
plugins: {
entries: {
"file-transfer": {
config: { nodes: Record<string, { allowReadPaths: string[] }> };
};
};
};
};
const list = root.plugins.entries["file-transfer"].config.nodes.n1.allowReadPaths;
expect(list.filter((p) => p === "/tmp/x").length).toBe(1);
});
});

View File

@@ -0,0 +1,383 @@
// Path policy for file-transfer node.invoke calls.
//
// Default behavior is DENY. The operator must explicitly opt in by adding
// a config block to ~/.openclaw/openclaw.json under
// `plugins.entries.file-transfer.config.nodes`. Without a matching block,
// every file operation is rejected before reaching the node.
//
// Schema (informal):
//
// "plugins": {
// "entries": {
// "file-transfer": {
// "config": {
// "nodes": {
// "<nodeId-or-displayName>": {
// "ask": "off" | "on-miss" | "always",
// "allowReadPaths": ["~/Screenshots/**", "/tmp/**"],
// "allowWritePaths": ["~/Downloads/**"],
// "denyPaths": ["**/.ssh/**", "**/.aws/**"],
// "maxBytes": 16777216,
// "followSymlinks": false
// },
// "*": { "ask": "on-miss" }
// }
// }
// }
// }
// }
//
// `ask` modes:
// off — silent: allow if matched, deny if not (today's default)
// on-miss — silent allow if matched; prompt operator if not matched
// always — prompt operator on every call (denyPaths still hard-deny)
//
// `denyPaths` always wins, even in `ask: always`.
// `allow-always` from the prompt appends the path back into allowReadPaths /
// allowWritePaths via mutateConfigFile.
//
// `followSymlinks` (default false): if false, the node-side handler
// realpaths the requested path (or its parent for new-file writes) BEFORE
// any I/O, and refuses with SYMLINK_REDIRECT if it differs from the
// requested path. This stops a symlink in user-controlled territory
// (e.g. ~/Downloads/evil → /etc) from redirecting an allowed-looking path
// to a disallowed canonical location. Set to true to opt back into the
// looser "follow + post-flight check" behavior, e.g. on macOS where
// /var → /private/var trips the check for /var/folders paths.
import os from "node:os";
import path from "node:path";
import { minimatch } from "minimatch";
import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation";
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
export type FilePolicyKind = "read" | "write";
export type FilePolicyAskMode = "off" | "on-miss" | "always";
export type FilePolicyDecision =
| { ok: true; reason: "matched-allow"; maxBytes?: number; followSymlinks: boolean }
| {
ok: true;
reason: "ask-always";
askMode: FilePolicyAskMode;
maxBytes?: number;
followSymlinks: boolean;
}
| {
ok: false;
code: "NO_POLICY" | "POLICY_DENIED";
reason: string;
askable: boolean;
askMode?: FilePolicyAskMode;
maxBytes?: number;
followSymlinks?: boolean;
};
type NodeFilePolicyConfig = {
ask?: FilePolicyAskMode;
allowReadPaths?: string[];
allowWritePaths?: string[];
denyPaths?: string[];
maxBytes?: number;
followSymlinks?: boolean;
};
type FilePolicyConfig = Record<string, NodeFilePolicyConfig>;
function asFilePolicyConfig(value: unknown): FilePolicyConfig | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as FilePolicyConfig;
}
function readFilePolicyConfigFromPluginConfig(pluginConfig: unknown): FilePolicyConfig | null {
if (!pluginConfig || typeof pluginConfig !== "object" || Array.isArray(pluginConfig)) {
return null;
}
const nodes = (pluginConfig as { nodes?: unknown }).nodes;
return asFilePolicyConfig(nodes);
}
function readPluginConfigFromRuntimeConfig(): Record<string, unknown> | null {
const cfg = getRuntimeConfig();
const plugins = (cfg as { plugins?: unknown }).plugins;
if (!plugins || typeof plugins !== "object") {
return null;
}
const entries = (plugins as { entries?: unknown }).entries;
if (!entries || typeof entries !== "object") {
return null;
}
const entry = (entries as Record<string, unknown>)["file-transfer"];
if (!entry || typeof entry !== "object") {
return null;
}
const pluginConfig = (entry as { config?: unknown }).config;
return pluginConfig && typeof pluginConfig === "object" && !Array.isArray(pluginConfig)
? (pluginConfig as Record<string, unknown>)
: null;
}
function readFilePolicyConfig(pluginConfig?: Record<string, unknown>): FilePolicyConfig | null {
return (
readFilePolicyConfigFromPluginConfig(readPluginConfigFromRuntimeConfig()) ??
readFilePolicyConfigFromPluginConfig(pluginConfig)
);
}
function expandTilde(p: string): string {
if (p.startsWith("~/") || p === "~") {
return path.join(os.homedir(), p.slice(p === "~" ? 1 : 2));
}
return p;
}
function normalizeGlobs(patterns: string[] | undefined): string[] {
if (!Array.isArray(patterns)) {
return [];
}
return patterns
.filter((p): p is string => typeof p === "string" && p.trim().length > 0)
.map((p) => expandTilde(p.trim()));
}
function matchesAny(target: string, patterns: string[]): boolean {
const normalizedTarget = target.replace(/\\/gu, "/");
for (const pattern of patterns) {
const normalizedPattern = pattern.replace(/\\/gu, "/");
if (
minimatch(target, pattern, { dot: true }) ||
minimatch(normalizedTarget, normalizedPattern, { dot: true })
) {
return true;
}
}
return false;
}
function resolveNodePolicy(
config: FilePolicyConfig,
nodeId: string,
nodeDisplayName?: string,
): { key: string; entry: NodeFilePolicyConfig } | null {
const candidates = [nodeId, nodeDisplayName].filter(
(k): k is string => typeof k === "string" && k.length > 0,
);
for (const key of candidates) {
if (config[key]) {
return { key, entry: config[key] };
}
}
if (config["*"]) {
return { key: "*", entry: config["*"] };
}
return null;
}
function normalizeAskMode(value: unknown): FilePolicyAskMode {
if (value === "on-miss" || value === "always" || value === "off") {
return value;
}
return "off";
}
/**
* Evaluate whether (nodeId, kind, path) is permitted.
*
* Resolution order:
* 1. No file-transfer config or no entry for this node → NO_POLICY (deny,
* not askable — operator hasn't opted in at all).
* 2. denyPaths matches → POLICY_DENIED, not askable (hard deny).
* 3. ask=always → ask-always (prompt every time).
* 4. allowPaths matches → matched-allow (silent allow).
* 5. ask=on-miss → POLICY_DENIED with askable=true.
* 6. ask=off (or unset) → POLICY_DENIED, not askable.
*/
/**
* Reject any path whose RAW string contains a ".." segment. Checking the
* raw string (not the normalized form) is the point — `posix.normalize`
* collapses "/allowed/../etc/passwd" to "/etc/passwd", which would defeat
* the check. We want to flag the literal traversal sequence the agent
* passed in, before any glob match runs.
*
* Without this, "/allowed/../etc/passwd" matches the glob "/allowed/**"
* pre-realpath, so the node fetches the bytes before the post-flight
* canonical-path check denies — too late, the bytes already crossed the
* node→gateway boundary.
*
* Treats backslash and forward slash as equivalent separators so a Windows
* node can't be hit with "C:\\allowed\\..\\Windows\\system.ini".
*/
function containsParentRefSegment(p: string): boolean {
const unified = p.replace(/\\/gu, "/");
return unified.split("/").includes("..");
}
export function evaluateFilePolicy(input: {
nodeId: string;
nodeDisplayName?: string;
kind: FilePolicyKind;
path: string;
pluginConfig?: Record<string, unknown>;
}): FilePolicyDecision {
// Reject literal traversal sequences before consulting any allow/deny
// glob list. minimatch on the raw string can wrongly accept
// "/allowed/../etc/passwd" against "/allowed/**".
if (containsParentRefSegment(input.path)) {
return {
ok: false,
code: "POLICY_DENIED",
reason: "path contains '..' segments; reject before glob match",
askable: false,
};
}
const config = readFilePolicyConfig(input.pluginConfig);
if (!config) {
return {
ok: false,
code: "NO_POLICY",
reason:
"no plugins.entries.file-transfer.config.nodes config; file-transfer is deny-by-default until configured",
askable: false,
};
}
const resolved = resolveNodePolicy(config, input.nodeId, input.nodeDisplayName);
if (!resolved) {
return {
ok: false,
code: "NO_POLICY",
reason: `no file-transfer policy entry for "${input.nodeDisplayName ?? input.nodeId}"; configure plugins.entries.file-transfer.config.nodes or "*"`,
askable: false,
};
}
const nodeConfig = resolved.entry;
const askMode = normalizeAskMode(nodeConfig.ask);
const maxBytes =
typeof nodeConfig.maxBytes === "number" && Number.isFinite(nodeConfig.maxBytes)
? Math.max(1, Math.floor(nodeConfig.maxBytes))
: undefined;
const followSymlinks = nodeConfig.followSymlinks === true;
// 1. Deny patterns always win.
const denyPatterns = normalizeGlobs(nodeConfig.denyPaths);
if (matchesAny(input.path, denyPatterns)) {
return {
ok: false,
code: "POLICY_DENIED",
reason: "path matches a denyPaths pattern",
askable: false,
askMode,
maxBytes,
followSymlinks,
};
}
// 2. ask=always: prompt every time even if matched.
if (askMode === "always") {
return { ok: true, reason: "ask-always", askMode, maxBytes, followSymlinks };
}
// 3. Match against allow list for this kind.
const allowPatterns =
input.kind === "read"
? normalizeGlobs(nodeConfig.allowReadPaths)
: normalizeGlobs(nodeConfig.allowWritePaths);
if (allowPatterns.length > 0 && matchesAny(input.path, allowPatterns)) {
return { ok: true, reason: "matched-allow", maxBytes, followSymlinks };
}
// 4. No allow match. Either askable on miss or hard-deny.
if (askMode === "on-miss") {
return {
ok: false,
code: "POLICY_DENIED",
reason: `path does not match any allow${input.kind === "read" ? "Read" : "Write"}Paths pattern`,
askable: true,
askMode,
maxBytes,
followSymlinks,
};
}
return {
ok: false,
code: "POLICY_DENIED",
reason:
allowPatterns.length === 0
? `no allow${input.kind === "read" ? "Read" : "Write"}Paths configured`
: `path does not match any allow${input.kind === "read" ? "Read" : "Write"}Paths pattern`,
askable: false,
askMode,
maxBytes,
followSymlinks,
};
}
/**
* Persist an "allow-always" approval by appending the path to the
* relevant allowReadPaths / allowWritePaths list for the node. Uses
* mutateConfigFile so the change survives gateway restarts.
*
* Inserts under whichever key matched the policy (per-node entry, or
* the "*" wildcard if that's what was hit). If no entry exists yet,
* creates one keyed by nodeDisplayName ?? nodeId.
*/
/**
* Reject special object keys that would mutate the prototype chain when
* used as a property name (e.g. `__proto__` setter on a plain object).
* The nodeDisplayName comes from paired-node metadata which we don't
* fully control; refuse to persist policy under a key that could corrupt
* the plugin policy container's prototype.
*/
function assertSafeConfigKey(key: string): string {
if (key === "__proto__" || key === "prototype" || key === "constructor") {
throw new Error(`refusing to persist file-transfer policy under unsafe key: ${key}`);
}
return key;
}
export async function persistAllowAlways(input: {
nodeId: string;
nodeDisplayName?: string;
kind: FilePolicyKind;
path: string;
}): Promise<void> {
const field = input.kind === "read" ? "allowReadPaths" : "allowWritePaths";
await mutateConfigFile({
afterWrite: { mode: "none", reason: "file-transfer allow-always policy update" },
mutate: (draft) => {
// Plugin config is intentionally plugin-owned; the root OpenClawConfig
// type only guarantees `Record<string, unknown>` here.
const root = draft as unknown as Record<string, unknown>;
const plugins = (root.plugins ??= {}) as Record<string, unknown>;
const entries = (plugins.entries ??= {}) as Record<string, unknown>;
const pluginEntry = (entries["file-transfer"] ??= {}) as Record<string, unknown>;
const pluginConfig = (pluginEntry.config ??= {}) as Record<string, unknown>;
const fileTransfer = (pluginConfig.nodes ??= {}) as Record<string, NodeFilePolicyConfig>;
// SECURITY: never persist allow-always under the "*" wildcard. An
// operator approving a path on node A must not silently grant the
// same path on every other node sharing the wildcard entry. Always
// write under the specific node's own entry, creating it if needed.
const candidates = [input.nodeId, input.nodeDisplayName].filter(
(k): k is string => typeof k === "string" && k.length > 0,
);
// Use hasOwnProperty so a node with displayName "constructor" doesn't
// accidentally hit Object.prototype.constructor and pretend to match.
let key = candidates.find((c) => Object.prototype.hasOwnProperty.call(fileTransfer, c));
if (!key) {
key = assertSafeConfigKey(input.nodeDisplayName ?? input.nodeId);
fileTransfer[key] = {};
}
const entry = fileTransfer[key];
const list = Array.isArray(entry[field]) ? entry[field] : [];
if (!list.includes(input.path)) {
list.push(input.path);
}
entry[field] = list;
},
});
}

View File

@@ -0,0 +1,58 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { validateTarUncompressedBudget } from "./dir-fetch-tool.js";
let tmpRoot: string;
beforeEach(async () => {
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "dir-fetch-tool-test-")));
});
afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true });
});
async function tarDirectory(dir: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
const child = spawn(tarBin, ["-czf", "-", "-C", dir, "."], {
stdio: ["ignore", "pipe", "pipe"],
});
const chunks: Buffer[] = [];
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("close", (code) => {
if (code !== 0) {
reject(new Error(`tar exited ${code}: ${stderr}`));
return;
}
resolve(Buffer.concat(chunks));
});
child.on("error", reject);
});
}
const testUnlessWindows = process.platform === "win32" ? it.skip : it;
describe("validateTarUncompressedBudget", () => {
testUnlessWindows(
"rejects an archive before extraction when expanded bytes exceed budget",
async () => {
await fs.writeFile(path.join(tmpRoot, "zeros.txt"), "0".repeat(128));
const tarBuffer = await tarDirectory(tmpRoot);
await expect(validateTarUncompressedBudget(tarBuffer, 64)).resolves.toMatchObject({
ok: false,
});
await expect(validateTarUncompressedBudget(tarBuffer, 256)).resolves.toMatchObject({
ok: true,
});
},
);
});

View File

@@ -0,0 +1,705 @@
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
callGatewayTool,
listNodes,
resolveNodeIdFromList,
type AnyAgentTool,
type NodeListNode,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
import { Type } from "typebox";
import { appendFileTransferAudit } from "../shared/audit.js";
import { throwFromNodePayload } from "../shared/errors.js";
import { IMAGE_MIME_INLINE_SET, mimeFromExtension } from "../shared/mime.js";
import {
humanSize,
readBoolean,
readClampedInt,
readGatewayCallOptions,
readTrimmedString,
} from "../shared/params.js";
const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
const FILE_TRANSFER_SUBDIR = "file-transfer";
// Cap how many local file paths we surface in details.media.mediaUrls.
// Larger trees still land on disk but we don't spam the channel adapter
// with hundreds of attachments.
const MEDIA_URL_CAP = 25;
// Hard timeout for gateway-side tar processes.
const TAR_UNPACK_TIMEOUT_MS = 60_000;
// Cap on number of entries pre-validated. The compressed tar is already
// capped at DIR_FETCH_HARD_MAX_BYTES upstream, and we walk the unpacked
// tree to compute hashes — TAR_UNPACK_MAX_ENTRIES bounds how much work
// that walk can do.
const TAR_UNPACK_MAX_ENTRIES = 5000;
// Hard caps on uncompressed extraction. Defends against decompression-bomb
// archives that compress to <16MB but expand to gigabytes. Both caps are
// enforced during the post-extract walk: total bytes summed across entries
// and per-file size to bound any single fs.stat / hash operation.
const DIR_FETCH_MAX_UNCOMPRESSED_BYTES = 64 * 1024 * 1024;
const DIR_FETCH_MAX_SINGLE_FILE_BYTES = 16 * 1024 * 1024;
const DirFetchToolSchema = Type.Object({
node: Type.String({
description: "Node id, name, or IP. Resolves the same way as the nodes tool.",
}),
path: Type.String({
description: "Absolute path to the directory on the node to fetch. Canonicalized server-side.",
}),
maxBytes: Type.Optional(
Type.Number({
description:
"Max gzipped tarball bytes to fetch. Default 8 MB, hard ceiling 16 MB (single round-trip).",
}),
),
includeDotfiles: Type.Optional(
Type.Boolean({
description: "Reserved for v2; currently always includes dotfiles (v1 quirk in BSD tar).",
}),
),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
});
async function computeFileSha256(filePath: string): Promise<string> {
// Stream the hash so we never pull a whole large file into memory.
// file_fetch caps single files at 16MB, but unpacked dir_fetch entries
// share the 64MB uncompressed budget — better to stream regardless.
const hash = crypto.createHash("sha256");
const handle = await fs.open(filePath, "r");
try {
const chunkSize = 64 * 1024;
const buf = Buffer.allocUnsafe(chunkSize);
while (true) {
const { bytesRead } = await handle.read(buf, 0, chunkSize, null);
if (bytesRead === 0) {
break;
}
hash.update(buf.subarray(0, bytesRead));
}
} finally {
await handle.close();
}
return hash.digest("hex");
}
/**
* Run two passes against the buffer to enumerate entries BEFORE we extract:
*
* 1. `tar -tf -` produces names ONLY, one per line. This is whitespace-safe
* because each line is exactly one path; no parsing of fixed columns.
* Used to validate paths (reject absolute, '..' traversal).
* 2. `tar -tvf -` adds type info via the `ls -l`-style perm prefix.
* Used ONLY to detect symlinks / hardlinks / non-regular entries via
* the FIRST CHARACTER of each line, never the path column.
*
* Size limits are enforced at the *extraction* step instead — the tar
* unpack process is bounded by the maxBytes we already pass through, and
* the post-extract walkDir is hard-capped by TAR_UNPACK_MAX_ENTRIES.
* Trying to parse uncompressed sizes from `tar -tvf` output is fragile
* (filenames with whitespace shift the columns) and Aisle flagged that
* shape as a bypass primitive — drop it.
*/
async function listTarPaths(
tarBuffer: Buffer,
): Promise<{ ok: true; paths: string[] } | { ok: false; reason: string }> {
return new Promise((resolve) => {
const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
const child = spawn(tarBin, ["-tzf", "-"], { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
let aborted = false;
const watchdog = setTimeout(() => {
aborted = true;
try {
child.kill("SIGKILL");
} catch {
/* gone */
}
resolve({ ok: false, reason: "tar -tzf timed out" });
}, 30_000);
child.stdout.on("data", (c: Buffer) => {
stdout += c.toString();
if (stdout.length > 32 * 1024 * 1024) {
aborted = true;
try {
child.kill("SIGKILL");
} catch {
/* gone */
}
clearTimeout(watchdog);
resolve({ ok: false, reason: "tar -tzf output too large" });
}
});
child.stderr.on("data", (c: Buffer) => {
stderr += c.toString();
});
child.on("close", (code) => {
clearTimeout(watchdog);
if (aborted) {
return;
}
if (code !== 0) {
resolve({ ok: false, reason: `tar -tzf exited ${code}: ${stderr.slice(0, 200)}` });
return;
}
// tar -tf emits one path per line with literal newlines as record
// separators. Filenames containing newlines are exotic enough that
// refusing them is safer than trying to parse around them.
const paths = stdout.split("\n").filter((l) => l.length > 0);
resolve({ ok: true, paths });
});
child.on("error", (e) => {
clearTimeout(watchdog);
if (!aborted) {
resolve({ ok: false, reason: `tar -tzf error: ${String(e)}` });
}
});
child.stdin.end(tarBuffer);
});
}
async function listTarTypeChars(
tarBuffer: Buffer,
): Promise<{ ok: true; typeChars: string[] } | { ok: false; reason: string }> {
return new Promise((resolve) => {
const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
const child = spawn(tarBin, ["-tzvf", "-"], { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
let aborted = false;
const watchdog = setTimeout(() => {
aborted = true;
try {
child.kill("SIGKILL");
} catch {
/* gone */
}
resolve({ ok: false, reason: "tar -tzvf timed out" });
}, 30_000);
child.stdout.on("data", (c: Buffer) => {
stdout += c.toString();
if (stdout.length > 32 * 1024 * 1024) {
aborted = true;
try {
child.kill("SIGKILL");
} catch {
/* gone */
}
clearTimeout(watchdog);
resolve({ ok: false, reason: "tar -tzvf output too large" });
}
});
child.stderr.on("data", (c: Buffer) => {
stderr += c.toString();
});
child.on("close", (code) => {
clearTimeout(watchdog);
if (aborted) {
return;
}
if (code !== 0) {
resolve({ ok: false, reason: `tar -tzvf exited ${code}: ${stderr.slice(0, 200)}` });
return;
}
// Take only the first character of each line — the entry type.
// We don't touch the rest of the line (path/size/etc) so filenames
// with whitespace can't shift our parser.
const typeChars = stdout
.split("\n")
.filter((l) => l.length > 0)
.map((l) => l.charAt(0));
resolve({ ok: true, typeChars });
});
child.on("error", (e) => {
clearTimeout(watchdog);
if (!aborted) {
resolve({ ok: false, reason: `tar -tzvf error: ${String(e)}` });
}
});
child.stdin.end(tarBuffer);
});
}
async function preValidateTarball(
tarBuffer: Buffer,
): Promise<{ ok: true } | { ok: false; reason: string }> {
const namesResult = await listTarPaths(tarBuffer);
if (!namesResult.ok) {
return namesResult;
}
const paths = namesResult.paths;
if (paths.length > TAR_UNPACK_MAX_ENTRIES) {
return {
ok: false,
reason: `archive contains ${paths.length} entries; limit ${TAR_UNPACK_MAX_ENTRIES}`,
};
}
const typesResult = await listTarTypeChars(tarBuffer);
if (!typesResult.ok) {
return typesResult;
}
const typeChars = typesResult.typeChars;
// The two passes should report the same number of entries; if they
// don't, something exotic is going on (filenames with newlines, etc.)
// and we refuse defensively.
if (typeChars.length !== paths.length) {
return {
ok: false,
reason: `tar -tzf and tar -tzvf disagree on entry count (${paths.length} vs ${typeChars.length}); refusing`,
};
}
for (let i = 0; i < paths.length; i++) {
const entryPath = paths[i];
const t = typeChars[i];
if (t === "l" || t === "h") {
return { ok: false, reason: `archive contains link entry: ${entryPath}` };
}
if (t !== "-" && t !== "d") {
return { ok: false, reason: `archive contains non-regular entry type '${t}': ${entryPath}` };
}
if (path.isAbsolute(entryPath)) {
return { ok: false, reason: `archive contains absolute path: ${entryPath}` };
}
const norm = path.posix.normalize(entryPath);
if (norm === ".." || norm.startsWith("../") || norm.includes("/../")) {
return { ok: false, reason: `archive contains '..' traversal: ${entryPath}` };
}
// Reject backslash-containing names too — refuses Windows-style
// traversal in archives produced by an attacker on a Windows node.
if (entryPath.includes("\\")) {
return { ok: false, reason: `archive contains backslash in path: ${entryPath}` };
}
}
return { ok: true };
}
export async function validateTarUncompressedBudget(
tarBuffer: Buffer,
maxBytes = DIR_FETCH_MAX_UNCOMPRESSED_BYTES,
): Promise<{ ok: true } | { ok: false; reason: string }> {
return new Promise((resolve) => {
const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
const child = spawn(tarBin, ["-xOzf", "-"], { stdio: ["pipe", "pipe", "pipe"] });
let totalBytes = 0;
let stderr = "";
let settled = false;
let watchdog: ReturnType<typeof setTimeout>;
const finish = (result: { ok: true } | { ok: false; reason: string }): void => {
if (settled) {
return;
}
settled = true;
clearTimeout(watchdog);
resolve(result);
};
watchdog = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
/* gone */
}
finish({ ok: false, reason: "tar uncompressed budget validation timed out" });
}, TAR_UNPACK_TIMEOUT_MS);
child.stdout.on("data", (chunk: Buffer) => {
totalBytes += chunk.byteLength;
if (totalBytes > maxBytes) {
try {
child.kill("SIGKILL");
} catch {
/* gone */
}
finish({
ok: false,
reason: `archive expands past uncompressed budget ${maxBytes} bytes`,
});
}
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
if (stderr.length > 4096) {
stderr = stderr.slice(-4096);
}
});
child.on("close", (code) => {
if (settled) {
return;
}
if (code !== 0) {
finish({
ok: false,
reason: `tar uncompressed budget validation exited ${code}: ${stderr.slice(0, 200)}`,
});
return;
}
finish({ ok: true });
});
child.on("error", (error) => {
finish({
ok: false,
reason: `tar uncompressed budget validation error: ${String(error)}`,
});
});
child.stdin.on("error", (error: NodeJS.ErrnoException) => {
if (settled && error.code === "EPIPE") {
return;
}
finish({
ok: false,
reason: `tar uncompressed budget validation input error: ${String(error)}`,
});
});
child.stdin.end(tarBuffer);
});
}
type UnpackedFileEntry = {
relPath: string;
size: number;
mimeType: string;
sha256: string;
localPath: string;
};
/**
* Unpack a gzipped tarball into a target directory via `tar -xzf -`.
* Caller MUST have run `preValidateTarball` first — this function trusts
* that the archive contains only regular files / dirs with relative,
* non-traversing paths. Without that pre-validation, raw `tar -xzf` is
* unsafe (tarbomb, symlink-then-write tricks, decompression bomb).
*
* The `-P` flag is intentionally omitted so absolute paths in the
* archive are stripped to relative ones (defense-in-depth on top of the
* pre-validation rejection). A hard wall-clock timeout caps the unpack
* at TAR_UNPACK_TIMEOUT_MS to avoid hangs.
*
* BSD tar (macOS) and GNU tar disagree on flags: `--no-overwrite-dir` is
* GNU-only and BSD tar rejects it. We use only flags both implementations
* accept. Defense-in-depth comes from the pre-validation step instead.
*
* `--no-same-owner` and `--no-same-permissions` are accepted by both BSD
* and GNU tar. They prevent the archive from setting file ownership
* (uid/gid) and dangerous mode bits (setuid/setgid/world-writable) on
* the gateway filesystem. If the gateway is ever run as root or with
* elevated privileges, a malicious node could otherwise plant
* privileged executables here.
*/
async function unpackTar(tarBuffer: Buffer, destDir: string): Promise<void> {
await fs.mkdir(destDir, { recursive: true, mode: 0o700 });
return new Promise((resolve, reject) => {
const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
const child = spawn(
tarBin,
["-xzf", "-", "-C", destDir, "--no-same-owner", "--no-same-permissions"],
{
stdio: ["pipe", "ignore", "pipe"],
},
);
let stderrOut = "";
const watchdog = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
/* already gone */
}
reject(new Error(`tar unpack timed out after ${TAR_UNPACK_TIMEOUT_MS}ms`));
}, TAR_UNPACK_TIMEOUT_MS);
child.stderr.on("data", (chunk: Buffer) => {
stderrOut += chunk.toString();
});
child.on("close", (code) => {
clearTimeout(watchdog);
if (code !== 0) {
reject(new Error(`tar unpack exited ${code}: ${stderrOut.slice(0, 300)}`));
return;
}
resolve();
});
child.on("error", (e) => {
clearTimeout(watchdog);
reject(e);
});
child.stdin.end(tarBuffer);
});
}
/**
* Walk a directory recursively, collecting file entries (skips directories).
* Skips symlinks — we don't want to follow links the archive might have
* carried in. Files only.
*/
async function walkDir(
dir: string,
rootDir: string,
): Promise<{ relPath: string; absPath: string }[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const results: { relPath: string; absPath: string }[] = [];
for (const entry of entries) {
const absPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const nested = await walkDir(absPath, rootDir);
results.push(...nested);
} else if (entry.isFile()) {
const relPath = path.relative(rootDir, absPath);
results.push({ relPath, absPath });
}
// Symlinks are intentionally ignored: don't follow them out of destDir.
}
return results;
}
export function createDirFetchTool(): AnyAgentTool {
return {
label: "Directory Fetch",
name: "dir_fetch",
description:
"Retrieve a directory tree from a paired node as a gzipped tarball, unpack it on the gateway, and return a manifest of saved paths. Use to pull source trees, asset folders, or log directories in a single round-trip. The unpacked files live on the GATEWAY (not your local machine); pass localPath into other tools or use file_fetch on individual entries to ship them elsewhere. Rejects trees larger than 16 MB compressed. Requires operator opt-in: gateway.nodes.allowCommands must include 'dir.fetch' AND plugins.entries.file-transfer.config.nodes.<node>.allowReadPaths must match the directory path.",
parameters: DirFetchToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const node = readTrimmedString(params, "node");
const dirPath = readTrimmedString(params, "path");
if (!node) {
throw new Error("node required");
}
if (!dirPath) {
throw new Error("path required");
}
const maxBytes = readClampedInt({
input: params,
key: "maxBytes",
defaultValue: DIR_FETCH_DEFAULT_MAX_BYTES,
hardMin: 1,
hardMax: DIR_FETCH_HARD_MAX_BYTES,
});
const includeDotfiles = readBoolean(params, "includeDotfiles", false);
const gatewayOpts = readGatewayCallOptions(params);
const nodes: NodeListNode[] = await listNodes(gatewayOpts);
const nodeId = resolveNodeIdFromList(nodes, node, false);
const nodeMeta = nodes.find((n) => n.nodeId === nodeId);
const nodeDisplayName = nodeMeta?.displayName ?? node;
const startedAt = Date.now();
const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, {
nodeId,
command: "dir.fetch",
params: {
path: dirPath,
maxBytes,
includeDotfiles,
},
idempotencyKey: crypto.randomUUID(),
});
const payload =
raw?.payload && typeof raw.payload === "object" && !Array.isArray(raw.payload)
? (raw.payload as Record<string, unknown>)
: null;
if (!payload) {
await appendFileTransferAudit({
op: "dir.fetch",
nodeId,
nodeDisplayName,
requestedPath: dirPath,
decision: "error",
errorMessage: "invalid payload",
durationMs: Date.now() - startedAt,
});
throw new Error("invalid dir.fetch payload");
}
if (payload.ok === false) {
await appendFileTransferAudit({
op: "dir.fetch",
nodeId,
nodeDisplayName,
requestedPath: dirPath,
canonicalPath:
typeof payload.canonicalPath === "string" ? payload.canonicalPath : undefined,
decision: "error",
errorCode: typeof payload.code === "string" ? payload.code : undefined,
errorMessage: typeof payload.message === "string" ? payload.message : undefined,
durationMs: Date.now() - startedAt,
});
throwFromNodePayload("dir.fetch", payload);
}
const canonicalPath = typeof payload.path === "string" ? payload.path : "";
const tarBase64 = typeof payload.tarBase64 === "string" ? payload.tarBase64 : "";
const tarBytes = typeof payload.tarBytes === "number" ? payload.tarBytes : -1;
const sha256 = typeof payload.sha256 === "string" ? payload.sha256 : "";
const fileCount = typeof payload.fileCount === "number" ? payload.fileCount : 0;
if (!canonicalPath || !tarBase64 || tarBytes < 0 || !sha256) {
throw new Error("invalid dir.fetch payload (missing fields)");
}
const tarBuffer = Buffer.from(tarBase64, "base64");
if (tarBuffer.byteLength !== tarBytes) {
throw new Error(
`dir.fetch size mismatch: payload says ${tarBytes} bytes, decoded ${tarBuffer.byteLength}`,
);
}
const localSha256 = crypto.createHash("sha256").update(tarBuffer).digest("hex");
if (localSha256 !== sha256) {
throw new Error("dir.fetch sha256 mismatch (integrity failure)");
}
// Pre-validate before extraction. The node is in the trust boundary
// for v1, but a malicious or compromised node should not be able to
// pivot into arbitrary file write on the gateway via tar tricks.
// Rejects: symlinks, hardlinks, absolute paths, ".." traversal,
// entry counts and uncompressed sizes above the caps.
const validation = await preValidateTarball(tarBuffer);
if (!validation.ok) {
await appendFileTransferAudit({
op: "dir.fetch",
nodeId,
nodeDisplayName,
requestedPath: dirPath,
canonicalPath,
decision: "error",
errorCode: "UNSAFE_ARCHIVE",
errorMessage: validation.reason,
sizeBytes: tarBytes,
sha256,
durationMs: Date.now() - startedAt,
});
throw new Error(`dir.fetch UNSAFE_ARCHIVE: ${validation.reason}`);
}
const budget = await validateTarUncompressedBudget(tarBuffer);
if (!budget.ok) {
await appendFileTransferAudit({
op: "dir.fetch",
nodeId,
nodeDisplayName,
requestedPath: dirPath,
canonicalPath,
decision: "error",
errorCode: "TREE_TOO_LARGE",
errorMessage: budget.reason,
sizeBytes: tarBytes,
sha256,
durationMs: Date.now() - startedAt,
});
throw new Error(`dir.fetch UNCOMPRESSED_TOO_LARGE: ${budget.reason}`);
}
// Save tarball under the file-transfer subdir (no 2-min TTL).
const savedTar = await saveMediaBuffer(
tarBuffer,
"application/gzip",
FILE_TRANSFER_SUBDIR,
DIR_FETCH_HARD_MAX_BYTES,
);
const tarDir = path.dirname(savedTar.path);
const tarBaseName = path.basename(savedTar.path, path.extname(savedTar.path));
const unpackId = `dir-fetch-${tarBaseName}`;
const rootDir = path.join(tarDir, unpackId);
await unpackTar(tarBuffer, rootDir);
const walked = await walkDir(rootDir, rootDir);
const files: UnpackedFileEntry[] = [];
// Defense-in-depth budget on the *uncompressed* extraction. Compressed
// tar is bounded upstream; an attacker can still send a highly
// compressible bomb (gigabytes of zeros) that fits under that cap.
// Stop walking + clean up if the unpacked tree busts the budget.
let totalUncompressed = 0;
const abortAndCleanup = async (reason: string): Promise<never> => {
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => {});
await appendFileTransferAudit({
op: "dir.fetch",
nodeId,
nodeDisplayName,
requestedPath: dirPath,
canonicalPath,
decision: "error",
errorCode: "TREE_TOO_LARGE",
errorMessage: reason,
sizeBytes: tarBytes,
sha256,
durationMs: Date.now() - startedAt,
});
throw new Error(`dir.fetch UNCOMPRESSED_TOO_LARGE: ${reason}`);
};
for (const { relPath, absPath } of walked) {
let size = 0;
try {
const st = await fs.stat(absPath);
size = st.size;
} catch {
continue;
}
if (size > DIR_FETCH_MAX_SINGLE_FILE_BYTES) {
await abortAndCleanup(
`extracted file ${relPath} is ${size} bytes (limit ${DIR_FETCH_MAX_SINGLE_FILE_BYTES})`,
);
}
totalUncompressed += size;
if (totalUncompressed > DIR_FETCH_MAX_UNCOMPRESSED_BYTES) {
await abortAndCleanup(
`extracted tree exceeds uncompressed budget ${DIR_FETCH_MAX_UNCOMPRESSED_BYTES} bytes (decompression bomb?)`,
);
}
const mimeType = mimeFromExtension(relPath);
const fileSha256 = await computeFileSha256(absPath);
files.push({ relPath, size, mimeType, sha256: fileSha256, localPath: absPath });
}
const imageFiles = files.filter((f) => IMAGE_MIME_INLINE_SET.has(f.mimeType));
const nonImageFiles = files.filter((f) => !IMAGE_MIME_INLINE_SET.has(f.mimeType));
const allOrdered = [...imageFiles, ...nonImageFiles];
const droppedFromMedia = Math.max(0, allOrdered.length - MEDIA_URL_CAP);
const mediaUrls = allOrdered.slice(0, MEDIA_URL_CAP).map((f) => f.localPath);
const shortHash = sha256.slice(0, 12);
const mediaNote = droppedFromMedia
? ` (channel attaches first ${MEDIA_URL_CAP}; ${droppedFromMedia} more in details.files)`
: "";
const summaryText = `Fetched ${fileCount} files from ${canonicalPath} (${humanSize(tarBytes)} compressed, sha256:${shortHash}) — saved on the gateway under ${rootDir}/${mediaNote}`;
await appendFileTransferAudit({
op: "dir.fetch",
nodeId,
nodeDisplayName,
requestedPath: dirPath,
canonicalPath,
decision: "allowed",
sizeBytes: tarBytes,
sha256,
durationMs: Date.now() - startedAt,
});
return {
content: [{ type: "text" as const, text: summaryText }],
details: {
path: canonicalPath,
rootDir,
fileCount,
tarBytes,
sha256,
files,
media: {
mediaUrls,
},
},
};
},
};
}

View File

@@ -0,0 +1,156 @@
import crypto from "node:crypto";
import {
callGatewayTool,
listNodes,
resolveNodeIdFromList,
type AnyAgentTool,
type NodeListNode,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { Type } from "typebox";
import { appendFileTransferAudit } from "../shared/audit.js";
import { throwFromNodePayload } from "../shared/errors.js";
import { readClampedInt, readGatewayCallOptions, readTrimmedString } from "../shared/params.js";
const DIR_LIST_DEFAULT_MAX_ENTRIES = 200;
const DIR_LIST_HARD_MAX_ENTRIES = 5000;
const DirListToolSchema = Type.Object({
node: Type.String({
description: "Node id, name, or IP. Resolves the same way as the nodes tool.",
}),
path: Type.String({
description: "Absolute path to the directory on the node. Canonicalized server-side.",
}),
pageToken: Type.Optional(
Type.String({
description:
"Pagination token from a previous dir_list call. Omit to start from the beginning.",
}),
),
maxEntries: Type.Optional(
Type.Number({
description: `Max entries per page. Default ${DIR_LIST_DEFAULT_MAX_ENTRIES}, hard ceiling ${DIR_LIST_HARD_MAX_ENTRIES}.`,
}),
),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
});
export function createDirListTool(): AnyAgentTool {
return {
label: "Directory List",
name: "dir_list",
description:
"Retrieve a structured directory listing from a paired node. Returns file and subdirectory metadata (name, path, size, mimeType, isDir, mtime) without transferring file content. Use this to discover what files exist before fetching them with file_fetch. Pagination is offset-based; pass nextPageToken from the previous result. Requires operator opt-in: gateway.nodes.allowCommands must include 'dir.list' AND plugins.entries.file-transfer.config.nodes.<node>.allowReadPaths must match the directory path. Without policy configured, every call is denied.",
parameters: DirListToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const node = readTrimmedString(params, "node");
const dirPath = readTrimmedString(params, "path");
if (!node) {
throw new Error("node required");
}
if (!dirPath) {
throw new Error("path required");
}
const maxEntries = readClampedInt({
input: params,
key: "maxEntries",
defaultValue: DIR_LIST_DEFAULT_MAX_ENTRIES,
hardMin: 1,
hardMax: DIR_LIST_HARD_MAX_ENTRIES,
});
const pageToken =
typeof params.pageToken === "string" && params.pageToken.trim()
? params.pageToken.trim()
: undefined;
const gatewayOpts = readGatewayCallOptions(params);
const nodes: NodeListNode[] = await listNodes(gatewayOpts);
const nodeId = resolveNodeIdFromList(nodes, node, false);
const nodeMeta = nodes.find((n) => n.nodeId === nodeId);
const nodeDisplayName = nodeMeta?.displayName ?? node;
const startedAt = Date.now();
const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, {
nodeId,
command: "dir.list",
params: {
path: dirPath,
pageToken,
maxEntries,
},
idempotencyKey: crypto.randomUUID(),
});
const payload =
raw?.payload && typeof raw.payload === "object" && !Array.isArray(raw.payload)
? (raw.payload as Record<string, unknown>)
: null;
if (!payload) {
await appendFileTransferAudit({
op: "dir.list",
nodeId,
nodeDisplayName,
requestedPath: dirPath,
decision: "error",
errorMessage: "invalid payload",
durationMs: Date.now() - startedAt,
});
throw new Error("invalid dir.list payload");
}
if (payload.ok === false) {
await appendFileTransferAudit({
op: "dir.list",
nodeId,
nodeDisplayName,
requestedPath: dirPath,
canonicalPath:
typeof payload.canonicalPath === "string" ? payload.canonicalPath : undefined,
decision: "error",
errorCode: typeof payload.code === "string" ? payload.code : undefined,
errorMessage: typeof payload.message === "string" ? payload.message : undefined,
durationMs: Date.now() - startedAt,
});
throwFromNodePayload("dir.list", payload);
}
const canonicalPath = typeof payload.path === "string" ? payload.path : dirPath;
const entries = Array.isArray(payload.entries)
? (payload.entries as Array<Record<string, unknown>>)
: [];
const truncated = payload.truncated === true;
const nextPageToken =
typeof payload.nextPageToken === "string" ? payload.nextPageToken : undefined;
const fileCount = entries.filter((e) => !e.isDir).length;
const dirCount = entries.filter((e) => e.isDir).length;
const truncatedNote = truncated ? " (more entries available — pass nextPageToken)" : "";
const summary = `Listed ${canonicalPath}: ${fileCount} file${fileCount !== 1 ? "s" : ""}, ${dirCount} subdir${dirCount !== 1 ? "s" : ""}${truncatedNote}`;
await appendFileTransferAudit({
op: "dir.list",
nodeId,
nodeDisplayName,
requestedPath: dirPath,
canonicalPath,
decision: "allowed",
durationMs: Date.now() - startedAt,
});
return {
content: [{ type: "text" as const, text: summary }],
details: {
path: canonicalPath,
entries,
nextPageToken,
truncated,
},
};
},
};
}

View File

@@ -0,0 +1,198 @@
import crypto from "node:crypto";
import {
callGatewayTool,
listNodes,
resolveNodeIdFromList,
type AnyAgentTool,
type NodeListNode,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
import { Type } from "typebox";
import { appendFileTransferAudit } from "../shared/audit.js";
import { throwFromNodePayload } from "../shared/errors.js";
import {
IMAGE_MIME_INLINE_SET,
TEXT_INLINE_MAX_BYTES,
TEXT_INLINE_MIME_SET,
} from "../shared/mime.js";
import { humanSize, readGatewayCallOptions, readTrimmedString } from "../shared/params.js";
const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
// Stash fetched files in a non-TTL subdir so a follow-up tool call within
// the same agent turn can still reference them. The default "inbound"
// subdir gets cleaned every 2 minutes which has bitten us in iMessage flows.
const FILE_TRANSFER_SUBDIR = "file-transfer";
const FileFetchToolSchema = Type.Object({
node: Type.String({
description: "Node id, name, or IP. Resolves the same way as the nodes tool.",
}),
path: Type.String({
description: "Absolute path to the file on the node. Canonicalized server-side.",
}),
maxBytes: Type.Optional(
Type.Number({
description: "Max bytes to fetch. Default 8 MB, hard ceiling 16 MB (single round-trip).",
}),
),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
});
export function createFileFetchTool(): AnyAgentTool {
return {
label: "File Fetch",
name: "file_fetch",
description:
"Retrieve a file from a paired node by absolute path. Returns image content blocks for image MIME types, inlines small text files (≤8 KB) as text content, and saves everything else under the gateway media store with a path you can pass to file_write or other tools. Use this for screenshots, photos, receipts, logs, source files. Pair with file_write to copy a file from one node to another (no exec/cp shell-out needed). Requires operator opt-in: gateway.nodes.allowCommands must include 'file.fetch' AND plugins.entries.file-transfer.config.nodes.<node>.allowReadPaths must match the path. Without policy configured, every call is denied.",
parameters: FileFetchToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const node = readTrimmedString(params, "node");
const filePath = readTrimmedString(params, "path");
if (!node) {
throw new Error("node required");
}
if (!filePath) {
throw new Error("path required");
}
const requestedMax =
typeof params.maxBytes === "number" && Number.isFinite(params.maxBytes)
? Math.floor(params.maxBytes)
: FILE_FETCH_DEFAULT_MAX_BYTES;
const maxBytes = Math.max(1, Math.min(requestedMax, FILE_FETCH_HARD_MAX_BYTES));
const gatewayOpts = readGatewayCallOptions(params);
const nodes: NodeListNode[] = await listNodes(gatewayOpts);
const nodeId = resolveNodeIdFromList(nodes, node, false);
const nodeMeta = nodes.find((n) => n.nodeId === nodeId);
const nodeDisplayName = nodeMeta?.displayName ?? node;
const startedAt = Date.now();
const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, {
nodeId,
command: "file.fetch",
params: {
path: filePath,
maxBytes,
},
idempotencyKey: crypto.randomUUID(),
});
const payload =
raw?.payload && typeof raw.payload === "object" && !Array.isArray(raw.payload)
? (raw.payload as Record<string, unknown>)
: null;
if (!payload) {
await appendFileTransferAudit({
op: "file.fetch",
nodeId,
nodeDisplayName,
requestedPath: filePath,
decision: "error",
errorMessage: "invalid payload",
durationMs: Date.now() - startedAt,
});
throw new Error("invalid file.fetch payload");
}
if (payload.ok === false) {
await appendFileTransferAudit({
op: "file.fetch",
nodeId,
nodeDisplayName,
requestedPath: filePath,
canonicalPath:
typeof payload.canonicalPath === "string" ? payload.canonicalPath : undefined,
decision: "error",
errorCode: typeof payload.code === "string" ? payload.code : undefined,
errorMessage: typeof payload.message === "string" ? payload.message : undefined,
durationMs: Date.now() - startedAt,
});
throwFromNodePayload("file.fetch", payload);
}
// Type-checks, NOT truthy-checks: an empty file legitimately has
// size=0 and base64="". Rejecting falsy values would block zero-byte
// round-trips through file_fetch → file_write.
const canonicalPath = typeof payload.path === "string" ? payload.path : "";
const size = typeof payload.size === "number" ? payload.size : -1;
const mimeType = typeof payload.mimeType === "string" ? payload.mimeType : "";
const hasBase64 = typeof payload.base64 === "string";
const base64 = hasBase64 ? (payload.base64 as string) : "";
const sha256 = typeof payload.sha256 === "string" ? payload.sha256 : "";
if (!canonicalPath || size < 0 || !mimeType || !hasBase64 || !sha256) {
throw new Error("invalid file.fetch payload (missing fields)");
}
const buffer = Buffer.from(base64, "base64");
if (buffer.byteLength !== size) {
throw new Error(
`file.fetch size mismatch: payload says ${size} bytes, decoded ${buffer.byteLength}`,
);
}
const localSha256 = crypto.createHash("sha256").update(buffer).digest("hex");
if (localSha256 !== sha256) {
throw new Error("file.fetch sha256 mismatch (integrity failure)");
}
const saved = await saveMediaBuffer(
buffer,
mimeType,
FILE_TRANSFER_SUBDIR,
FILE_FETCH_HARD_MAX_BYTES,
);
const localPath = saved.path;
const isInlineImage = IMAGE_MIME_INLINE_SET.has(mimeType);
const isInlineText = TEXT_INLINE_MIME_SET.has(mimeType) && size <= TEXT_INLINE_MAX_BYTES;
const content: Array<
{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }
> = [];
if (isInlineImage) {
content.push({ type: "image", data: base64, mimeType });
} else if (isInlineText) {
const text = buffer.toString("utf-8");
content.push({
type: "text",
text: `Fetched ${canonicalPath} (${humanSize(size)}, ${mimeType}, sha256:${sha256.slice(0, 12)}) saved at ${localPath}\n\n--- contents ---\n${text}`,
});
} else {
const shortHash = sha256.slice(0, 12);
content.push({
type: "text",
text: `Fetched ${canonicalPath} (${humanSize(size)}, ${mimeType}, sha256:${shortHash}) saved at ${localPath}`,
});
}
await appendFileTransferAudit({
op: "file.fetch",
nodeId,
nodeDisplayName,
requestedPath: filePath,
canonicalPath,
decision: "allowed",
sizeBytes: size,
sha256,
durationMs: Date.now() - startedAt,
});
return {
content,
details: {
path: canonicalPath,
size,
mimeType,
sha256,
localPath,
mediaId: saved.id,
media: {
mediaUrls: [localPath],
},
},
};
},
};
}

View File

@@ -0,0 +1,209 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import {
callGatewayTool,
listNodes,
resolveNodeIdFromList,
type AnyAgentTool,
type NodeListNode,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveMediaBufferPath } from "openclaw/plugin-sdk/media-store";
import { Type } from "typebox";
import { appendFileTransferAudit } from "../shared/audit.js";
import { throwFromNodePayload } from "../shared/errors.js";
import {
humanSize,
readBoolean,
readGatewayCallOptions,
readTrimmedString,
} from "../shared/params.js";
const FILE_WRITE_HARD_MAX_BYTES = 16 * 1024 * 1024;
const FILE_WRITE_SCHEMA = Type.Object({
node: Type.String({ description: "Node id or display name to write the file on." }),
path: Type.String({
description: "Absolute path on the node to write. Canonicalized server-side.",
}),
contentBase64: Type.Optional(
Type.String({
description: "Base64-encoded bytes to write. Maximum 16 MB after decode.",
}),
),
sourceMediaId: Type.Optional(
Type.String({
description:
"Media id returned by file_fetch. Preferred for binary copies because bytes stay in the gateway media store.",
}),
),
mimeType: Type.Optional(
Type.String({
description: "Content type hint. Not validated against the content.",
}),
),
overwrite: Type.Optional(
Type.Boolean({
description: "Allow overwriting an existing file. Default false.",
default: false,
}),
),
createParents: Type.Optional(
Type.Boolean({
description: "Create missing parent directories (mkdir -p). Default false.",
default: false,
}),
),
});
async function readSourceBytes(input: {
contentBase64?: string;
sourceMediaId?: string;
}): Promise<{ buffer: Buffer; contentBase64: string; source: "inline" | "media" }> {
const sourceMediaId = input.sourceMediaId?.trim();
if (sourceMediaId) {
const mediaPath = await resolveMediaBufferPath(sourceMediaId, "file-transfer");
const stat = await fs.stat(mediaPath);
if (stat.size > FILE_WRITE_HARD_MAX_BYTES) {
throw new Error(
`sourceMediaId too large: ${stat.size} bytes; maximum is ${FILE_WRITE_HARD_MAX_BYTES} bytes`,
);
}
const buffer = await fs.readFile(mediaPath);
return { buffer, contentBase64: buffer.toString("base64"), source: "media" };
}
if (input.contentBase64 === undefined) {
throw new Error("contentBase64 or sourceMediaId required");
}
const buffer = Buffer.from(input.contentBase64, "base64");
return { buffer, contentBase64: input.contentBase64, source: "inline" };
}
type FileWriteSuccess = {
ok: true;
path: string;
size: number;
sha256: string;
overwritten: boolean;
};
type FileWriteError = {
ok: false;
code: string;
message: string;
canonicalPath?: string;
};
type FileWritePayload = FileWriteSuccess | FileWriteError;
export function createFileWriteTool(): AnyAgentTool {
return {
label: "File Write",
name: "file_write",
description:
"Write file bytes to a paired node by absolute path. Atomic write (temp + rename). Refuses to overwrite by default — pass overwrite=true to replace. Refuses to write through symlink targets unless policy explicitly allows following symlinks. Pair with file_fetch by passing its mediaId as sourceMediaId for binary copy. Requires operator opt-in: gateway.nodes.allowCommands must include 'file.write' AND plugins.entries.file-transfer.config.nodes.<node>.allowWritePaths must match the destination path. Without policy configured, every call is denied.",
parameters: FILE_WRITE_SCHEMA,
async execute(_toolCallId, params) {
const raw: Record<string, unknown> =
params && typeof params === "object" && !Array.isArray(params)
? (params as Record<string, unknown>)
: {};
const nodeQuery = readTrimmedString(raw, "node");
const filePath = readTrimmedString(raw, "path");
const contentBase64 = typeof raw.contentBase64 === "string" ? raw.contentBase64 : undefined;
const sourceMediaId = typeof raw.sourceMediaId === "string" ? raw.sourceMediaId : undefined;
const overwrite = readBoolean(raw, "overwrite", false);
const createParents = readBoolean(raw, "createParents", false);
if (!nodeQuery) {
throw new Error("node required");
}
if (!filePath) {
throw new Error("path required");
}
// Compute the sha256 of the bytes we're sending so the node can do
// an end-to-end integrity check after writing. This is always
// sender-side computed; ignore any caller-supplied expectedSha256
// to avoid the model passing a wrong hash and triggering an
// unintended unlink.
const sourceBytes = await readSourceBytes({ contentBase64, sourceMediaId });
const buffer = sourceBytes.buffer;
const expectedSha256 = crypto.createHash("sha256").update(buffer).digest("hex");
const gatewayOpts = readGatewayCallOptions(raw);
const nodes: NodeListNode[] = await listNodes(gatewayOpts);
const nodeId = resolveNodeIdFromList(nodes, nodeQuery, false);
const nodeMeta = nodes.find((n) => n.nodeId === nodeId);
const nodeDisplayName = nodeMeta?.displayName ?? nodeQuery;
const startedAt = Date.now();
const result = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, {
nodeId,
command: "file.write",
params: {
path: filePath,
contentBase64: sourceBytes.contentBase64,
overwrite,
createParents,
expectedSha256,
},
idempotencyKey: crypto.randomUUID(),
});
const payload = (result as { payload?: unknown })?.payload;
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
await appendFileTransferAudit({
op: "file.write",
nodeId,
nodeDisplayName,
requestedPath: filePath,
decision: "error",
errorMessage: "unexpected response from node",
sizeBytes: buffer.byteLength,
durationMs: Date.now() - startedAt,
});
throw new Error("unexpected file.write response from node");
}
const typed = payload as FileWritePayload;
if (!typed.ok) {
await appendFileTransferAudit({
op: "file.write",
nodeId,
nodeDisplayName,
requestedPath: filePath,
canonicalPath: typed.canonicalPath,
decision: "error",
errorCode: typed.code,
errorMessage: typed.message,
sizeBytes: buffer.byteLength,
durationMs: Date.now() - startedAt,
});
throwFromNodePayload("file.write", typed as unknown as Record<string, unknown>);
}
await appendFileTransferAudit({
op: "file.write",
nodeId,
nodeDisplayName,
requestedPath: filePath,
canonicalPath: typed.path,
decision: "allowed",
sizeBytes: typed.size,
sha256: typed.sha256,
durationMs: Date.now() - startedAt,
});
const overwriteNote = typed.overwritten ? " (overwrote existing file)" : "";
return {
content: [
{
type: "text" as const,
text: `Wrote ${typed.path} (${humanSize(typed.size)}, sha256:${typed.sha256.slice(0, 12)})${overwriteNote}`,
},
],
details: { ...typed, source: sourceBytes.source },
};
},
};
}

View File

@@ -1,4 +1,4 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Model } from "@mariozechner/pi-ai";
@@ -51,6 +51,24 @@ function buildGeminiModel(
};
}
function buildGoogleVertexModel(
overrides: Partial<Model<"google-vertex">> = {},
): Model<"google-vertex"> {
return {
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
api: "google-vertex",
provider: "google-vertex",
baseUrl: "https://{location}-aiplatform.googleapis.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
...overrides,
};
}
function buildSseResponse(events: unknown[]): Response {
const sse = `${events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")}data: [DONE]\n\n`;
const encoder = new TextEncoder();
@@ -302,18 +320,7 @@ describe("google transport stream", () => {
expect(hasGoogleVertexAuthorizedUserAdcSync()).toBe(true);
const model = {
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
api: "google-vertex",
provider: "google-vertex",
baseUrl: "https://{location}-aiplatform.googleapis.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
} satisfies Model<"google-vertex">;
const model = buildGoogleVertexModel();
const streamFn = createGoogleVertexTransportStreamFn();
const stream = await Promise.resolve(
@@ -353,6 +360,80 @@ describe("google transport stream", () => {
});
});
it("refreshes authorized_user ADC from the Windows APPDATA fallback for Google Vertex requests", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-appdata-adc-"));
const homeDir = path.join(tempDir, "home");
const appDataDir = path.join(tempDir, "AppData", "Roaming");
const fallbackDir = path.join(appDataDir, "gcloud");
const credentialsPath = path.join(fallbackDir, "application_default_credentials.json");
await mkdir(fallbackDir, { recursive: true });
await writeFile(
credentialsPath,
JSON.stringify({
type: "authorized_user",
client_id: "client-id",
client_secret: "client-secret",
refresh_token: "appdata-refresh-token",
}),
"utf8",
);
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", undefined);
vi.stubEnv("HOME", homeDir);
vi.stubEnv("APPDATA", appDataDir);
vi.stubEnv("GOOGLE_CLOUD_PROJECT", "vertex-project");
vi.stubEnv("GOOGLE_CLOUD_LOCATION", "global");
const tokenFetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ access_token: "ya29.appdata-token", expires_in: 3600 }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
guardedFetchMock.mockResolvedValueOnce(
buildSseResponse([
{
candidates: [{ content: { parts: [{ text: "ok" }] }, finishReason: "STOP" }],
},
]),
);
expect(hasGoogleVertexAuthorizedUserAdcSync()).toBe(true);
const streamFn = createGoogleVertexTransportStreamFn();
const stream = await Promise.resolve(
streamFn(
buildGoogleVertexModel(),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
} as Parameters<typeof streamFn>[1],
{
apiKey: "gcp-vertex-credentials",
fetch: tokenFetchMock,
} as Parameters<typeof streamFn>[2],
),
);
await stream.result();
expect(tokenFetchMock).toHaveBeenCalledWith(
"https://oauth2.googleapis.com/token",
expect.objectContaining({
body: expect.objectContaining({
get: expect.any(Function),
}),
method: "POST",
}),
);
const requestBody = tokenFetchMock.mock.calls[0]?.[1]?.body as URLSearchParams | undefined;
expect(requestBody?.get("refresh_token")).toBe("appdata-refresh-token");
expect(guardedFetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer ya29.appdata-token",
}),
}),
);
});
it("coerces replayed malformed tool-call args to an object for Google payloads", () => {
const params = buildGoogleGenerativeAiParams(buildGeminiModel(), {
messages: [

View File

@@ -40,8 +40,21 @@ function resolveGoogleApplicationCredentialsPath(
return existsSync(explicit) ? explicit : undefined;
}
const homeDir = normalizeOptionalString(env.HOME) ?? os.homedir();
const fallback = path.join(homeDir, ".config", "gcloud", "application_default_credentials.json");
return existsSync(fallback) ? fallback : undefined;
const homeFallback = path.join(
homeDir,
".config",
"gcloud",
"application_default_credentials.json",
);
if (existsSync(homeFallback)) {
return homeFallback;
}
const appDataDir = normalizeOptionalString(env.APPDATA);
if (!appDataDir) {
return undefined;
}
const appDataFallback = path.join(appDataDir, "gcloud", "application_default_credentials.json");
return existsSync(appDataFallback) ? appDataFallback : undefined;
}
async function readGoogleAuthorizedUserCredentials(

View File

@@ -24,6 +24,10 @@ export type ResolvedGoogleChatAccount = {
credentialsFile?: string;
};
export type GoogleChatConfigAccessorAccount = {
config: GoogleChatAccountConfig;
};
const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
const JsonRecordSchema = z.record(z.string(), z.unknown());
@@ -34,7 +38,7 @@ const {
} = createAccountListHelpers("googlechat");
export { listGoogleChatAccountIds, resolveDefaultGoogleChatAccountId };
function mergeGoogleChatAccountConfig(
export function mergeGoogleChatAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): GoogleChatAccountConfig {
@@ -62,6 +66,16 @@ function mergeGoogleChatAccountConfig(
return { ...defaultAccountShared, ...base } as GoogleChatAccountConfig;
}
export function resolveGoogleChatConfigAccessorAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): GoogleChatConfigAccessorAccount {
const accountId = normalizeAccountId(
params.accountId ?? params.cfg.channels?.googlechat?.defaultAccount,
);
return { config: mergeGoogleChatAccountConfig(params.cfg, accountId) };
}
function parseServiceAccount(value: unknown): Record<string, unknown> | null {
if (isSecretRef(value)) {
return null;

View File

@@ -0,0 +1,39 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect, it } from "vitest";
import { googlechatPlugin } from "./channel.js";
describe("googlechatPlugin config adapter", () => {
it("keeps read-only accessors from resolving service account SecretRefs", () => {
const cfg = {
secrets: {
providers: {
google_chat_service_account: {
source: "file",
path: "/tmp/openclaw-missing-google-chat-service-account",
mode: "singleValue",
},
},
},
channels: {
googlechat: {
serviceAccount: {
source: "file",
provider: "google_chat_service_account",
id: "value",
},
dm: {
allowFrom: ["users/123"],
},
defaultTo: "spaces/AAA",
},
},
} as OpenClawConfig;
expect(googlechatPlugin.config.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual([
"users/123",
]);
expect(googlechatPlugin.config.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe(
"spaces/AAA",
);
});
});

View File

@@ -15,7 +15,9 @@ export {
type OpenClawConfig,
} from "../runtime-api.js";
export {
type GoogleChatConfigAccessorAccount,
listGoogleChatAccountIds,
resolveGoogleChatConfigAccessorAccount,
resolveDefaultGoogleChatAccountId,
resolveGoogleChatAccount,
type ResolvedGoogleChatAccount,

View File

@@ -7,7 +7,9 @@ import {
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import {
type GoogleChatConfigAccessorAccount,
listGoogleChatAccountIds,
resolveGoogleChatConfigAccessorAccount,
resolveDefaultGoogleChatAccountId,
resolveGoogleChatAccount,
type ResolvedGoogleChatAccount,
@@ -24,10 +26,14 @@ const formatGoogleChatAllowFromEntry = (entry: string) =>
.replace(/^users\//i, ""),
);
const googleChatConfigAdapter = createScopedChannelConfigAdapter<ResolvedGoogleChatAccount>({
const googleChatConfigAdapter = createScopedChannelConfigAdapter<
ResolvedGoogleChatAccount,
GoogleChatConfigAccessorAccount
>({
sectionKey: "googlechat",
listAccountIds: listGoogleChatAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount),
resolveAccessorAccount: resolveGoogleChatConfigAccessorAccount,
defaultAccountId: resolveDefaultGoogleChatAccountId,
clearBaseFields: [
"serviceAccount",

View File

@@ -30,6 +30,8 @@ import {
isGoogleChatUserTarget,
listGoogleChatAccountIds,
normalizeGoogleChatTarget,
type GoogleChatConfigAccessorAccount,
resolveGoogleChatConfigAccessorAccount,
resolveDefaultGoogleChatAccountId,
resolveGoogleChatAccount,
type ChannelMessageActionAdapter,
@@ -65,10 +67,14 @@ const meta = {
markdownCapable: true,
};
const googleChatConfigAdapter = createScopedChannelConfigAdapter<ResolvedGoogleChatAccount>({
const googleChatConfigAdapter = createScopedChannelConfigAdapter<
ResolvedGoogleChatAccount,
GoogleChatConfigAccessorAccount
>({
sectionKey: "googlechat",
listAccountIds: listGoogleChatAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount),
resolveAccessorAccount: resolveGoogleChatConfigAccessorAccount,
defaultAccountId: resolveDefaultGoogleChatAccountId,
clearBaseFields: [
"serviceAccount",
@@ -80,13 +86,13 @@ const googleChatConfigAdapter = createScopedChannelConfigAdapter<ResolvedGoogleC
"botUser",
"name",
],
resolveAllowFrom: (account: ResolvedGoogleChatAccount) => account.config.dm?.allowFrom,
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: formatAllowFromEntry,
}),
resolveDefaultTo: (account: ResolvedGoogleChatAccount) => account.config.defaultTo,
resolveDefaultTo: (account) => account.config.defaultTo,
});
const googlechatActions: ChannelMessageActionAdapter = {

View File

@@ -12,7 +12,7 @@ import {
} from "openclaw/plugin-sdk/conversation-runtime";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime";
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { runInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
@@ -435,59 +435,74 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
},
});
await runPreparedInboundReplyTurn({
await runInboundReplyTurn({
channel: "imessage",
accountId: decision.route.accountId,
routeSessionKey: decision.route.sessionKey,
storePath,
ctxPayload,
recordInboundSession,
record: {
updateLastRoute:
!decision.isGroup && updateTarget
? {
sessionKey: decision.route.mainSessionKey,
channel: "imessage",
to: updateTarget,
accountId: decision.route.accountId,
mainDmOwnerPin:
pinnedMainDmOwner && decision.senderNormalized
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: decision.senderNormalized,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`imessage: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err) => {
logVerbose(`imessage: failed updating session meta: ${String(err)}`);
},
},
history: {
isGroup: decision.isGroup,
historyKey: decision.historyKey,
historyMap: groupHistories,
limit: historyLimit,
},
onPreDispatchFailure: () => settleReplyDispatcher({ dispatcher }),
runDispatch: () =>
dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
disableBlockStreaming:
typeof accountInfo.config.blockStreaming === "boolean"
? !accountInfo.config.blockStreaming
: undefined,
onModelSelected,
},
raw: decision,
adapter: {
ingest: () => ({
id: ctxPayload.MessageSid ?? `${ctxPayload.From}:${Date.now()}`,
timestamp: typeof ctxPayload.Timestamp === "number" ? ctxPayload.Timestamp : undefined,
rawText: ctxPayload.RawBody ?? "",
textForAgent: ctxPayload.BodyForAgent,
textForCommands: ctxPayload.CommandBody,
raw: decision,
}),
resolveTurn: () => ({
channel: "imessage",
accountId: decision.route.accountId,
routeSessionKey: decision.route.sessionKey,
storePath,
ctxPayload,
recordInboundSession,
record: {
updateLastRoute:
!decision.isGroup && updateTarget
? {
sessionKey: decision.route.mainSessionKey,
channel: "imessage",
to: updateTarget,
accountId: decision.route.accountId,
mainDmOwnerPin:
pinnedMainDmOwner && decision.senderNormalized
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: decision.senderNormalized,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`imessage: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err) => {
logVerbose(`imessage: failed updating session meta: ${String(err)}`);
},
},
history: {
isGroup: decision.isGroup,
historyKey: decision.historyKey,
historyMap: groupHistories,
limit: historyLimit,
},
onPreDispatchFailure: () => settleReplyDispatcher({ dispatcher }),
runDispatch: () =>
dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
disableBlockStreaming:
typeof accountInfo.config.blockStreaming === "boolean"
? !accountInfo.config.blockStreaming
: undefined,
onModelSelected,
},
}),
}),
},
});
}

View File

@@ -231,7 +231,7 @@ export async function monitorLineProvider(
});
const core = getLineRuntime();
const { dispatchResult } = await core.channel.turn.run({
const turnResult = await core.channel.turn.run({
channel: "line",
accountId: route.accountId,
raw: ctx,
@@ -316,6 +316,7 @@ export async function monitorLineProvider(
}),
},
});
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
if (!hasFinalInboundReplyDispatch(dispatchResult)) {
logVerbose(`line: no response generated for message from ${ctxPayload.From}`);
}

View File

@@ -142,6 +142,28 @@ export function createMatrixHandlerTestHarness(
};
},
);
const run = vi.fn(
async (params: Parameters<MatrixMonitorHandlerParams["core"]["channel"]["turn"]["run"]>[0]) => {
const input = await params.adapter.ingest(params.raw);
if (!input) {
return { admission: { kind: "drop" as const, reason: "ingest-null" }, dispatched: false };
}
const eventClass = (await params.adapter.classify?.(input)) ?? {
kind: "message" as const,
canStartAgentTurn: true,
};
const preflightResult = await params.adapter.preflight?.(input, eventClass);
const preflight =
preflightResult && "kind" in preflightResult
? { admission: preflightResult }
: (preflightResult ?? {});
const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
if ("runDispatch" in turn) {
return await runPrepared(turn);
}
throw new Error("matrix test helper only supports prepared turn dispatch");
},
);
const dmPolicy = options.dmPolicy ?? "open";
const allowFrom = options.allowFrom ?? (dmPolicy === "open" ? ["*"] : []);
const cfgForHandler =
@@ -229,6 +251,7 @@ export function createMatrixHandlerTestHarness(
}),
},
turn: {
run,
runPrepared,
},
reactions: {

View File

@@ -1829,106 +1829,127 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
onIdle: typingCallbacks.onIdle,
});
const { dispatchResult } = await core.channel.turn.runPrepared({
const turnResult = await core.channel.turn.run({
channel: "matrix",
accountId: _route.accountId,
routeSessionKey: _route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
updateLastRoute: isDirectMessage
? {
sessionKey: _route.mainSessionKey,
channel: "matrix",
to: `room:${roomId}`,
accountId: _route.accountId,
raw: event,
adapter: {
ingest: () => ({
id: _messageId,
rawText: bodyText,
textForAgent: ctxPayload.BodyForAgent,
textForCommands: ctxPayload.CommandBody,
raw: event,
}),
resolveTurn: () => ({
channel: "matrix",
accountId: _route.accountId,
routeSessionKey: _route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
updateLastRoute: isDirectMessage
? {
sessionKey: _route.mainSessionKey,
channel: "matrix",
to: `room:${roomId}`,
accountId: _route.accountId,
}
: undefined,
onRecordError: (err) => {
logger.warn("failed updating session meta", {
error: String(err),
storePath,
sessionKey: ctxPayload.SessionKey ?? _route.sessionKey,
});
},
},
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => {
markRunComplete();
markDispatchIdle();
},
}),
runDispatch: async () => {
if (
sharedDmContextNotice &&
markTrackedRoomIfFirst(sharedDmContextNoticeRooms, roomId)
) {
client
.sendMessage(roomId, {
msgtype: "m.notice",
body: sharedDmContextNotice,
})
.catch((err) => {
logVerboseMessage(
`matrix: failed sending shared DM session notice room=${roomId}: ${String(err)}`,
);
});
}
: undefined,
onRecordError: (err) => {
logger.warn("failed updating session meta", {
error: String(err),
storePath,
sessionKey: ctxPayload.SessionKey ?? _route.sessionKey,
});
},
},
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => {
markRunComplete();
markDispatchIdle();
return await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: async () => {
try {
return await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
skillFilter: roomConfig?.skills,
// Keep block streaming enabled when explicitly requested, even
// with draft previews on. The draft remains the live preview
// for the current assistant block, while block deliveries
// finalize completed blocks into their own preserved events.
disableBlockStreaming: !blockStreamingEnabled,
onPartialReply: draftStream
? (payload) => {
latestDraftFullText = payload.text ?? "";
suppressPreviewToolProgressForAnswerText(latestDraftFullText);
updateDraftFromLatestFullText();
}
: undefined,
onBlockReplyQueued: draftStream
? (payload, context) => {
if (payload.isCompactionNotice === true) {
return;
}
queueDraftBlockBoundary(payload, context);
}
: undefined,
// Reset draft boundary bookkeeping on assistant message
// boundaries so post-tool blocks stream from a fresh
// cumulative payload (payload.text resets upstream).
onAssistantMessageStart: draftStream
? () => {
resetDraftBlockOffsets();
resetPreviewToolProgress();
}
: undefined,
...buildPreviewToolProgressReplyOptions(),
onModelSelected,
},
});
} finally {
markRunComplete();
}
},
});
},
}),
runDispatch: async () => {
if (sharedDmContextNotice && markTrackedRoomIfFirst(sharedDmContextNoticeRooms, roomId)) {
client
.sendMessage(roomId, {
msgtype: "m.notice",
body: sharedDmContextNotice,
})
.catch((err) => {
logVerboseMessage(
`matrix: failed sending shared DM session notice room=${roomId}: ${String(err)}`,
);
});
}
return await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: async () => {
try {
return await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
skillFilter: roomConfig?.skills,
// Keep block streaming enabled when explicitly requested, even
// with draft previews on. The draft remains the live preview
// for the current assistant block, while block deliveries
// finalize completed blocks into their own preserved events.
disableBlockStreaming: !blockStreamingEnabled,
onPartialReply: draftStream
? (payload) => {
latestDraftFullText = payload.text ?? "";
suppressPreviewToolProgressForAnswerText(latestDraftFullText);
updateDraftFromLatestFullText();
}
: undefined,
onBlockReplyQueued: draftStream
? (payload, context) => {
if (payload.isCompactionNotice === true) {
return;
}
queueDraftBlockBoundary(payload, context);
}
: undefined,
// Reset draft boundary bookkeeping on assistant message
// boundaries so post-tool blocks stream from a fresh
// cumulative payload (payload.text resets upstream).
onAssistantMessageStart: draftStream
? () => {
resetDraftBlockOffsets();
resetPreviewToolProgress();
}
: undefined,
...buildPreviewToolProgressReplyOptions(),
onModelSelected,
},
});
} finally {
markRunComplete();
}
},
});
},
});
if (!turnResult.dispatched) {
return;
}
const { dispatchResult } = turnResult;
const { queuedFinal, counts } = dispatchResult;
if (finalReplyDeliveryFailed) {
if (retryableReplyDeliveryFailed) {

View File

@@ -8,18 +8,21 @@ import {
class FakeWebSocket implements MattermostWebSocketLike {
public readonly sent: string[] = [];
public pingCalls = 0;
public closeCalls = 0;
public terminateCalls = 0;
private openListeners: Array<() => void> = [];
private messageListeners: Array<(data: Buffer) => void | Promise<void>> = [];
private pongListeners: Array<(data: Buffer) => void> = [];
private closeListeners: Array<(code: number, reason: Buffer) => void> = [];
private errorListeners: Array<(err: unknown) => void> = [];
on(event: "open", listener: () => void): void;
on(event: "message", listener: (data: Buffer) => void | Promise<void>): void;
on(event: "pong", listener: (data: Buffer) => void): void;
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
on(event: "error", listener: (err: unknown) => void): void;
on(event: "open" | "message" | "close" | "error", listener: unknown): void {
on(event: "open" | "message" | "pong" | "close" | "error", listener: unknown): void {
if (event === "open") {
this.openListeners.push(listener as () => void);
return;
@@ -28,6 +31,10 @@ class FakeWebSocket implements MattermostWebSocketLike {
this.messageListeners.push(listener as (data: Buffer) => void | Promise<void>);
return;
}
if (event === "pong") {
this.pongListeners.push(listener as (data: Buffer) => void);
return;
}
if (event === "close") {
this.closeListeners.push(listener as (code: number, reason: Buffer) => void);
return;
@@ -35,6 +42,10 @@ class FakeWebSocket implements MattermostWebSocketLike {
this.errorListeners.push(listener as (err: unknown) => void);
}
ping(): void {
this.pingCalls++;
}
send(data: string): void {
this.sent.push(data);
}
@@ -59,6 +70,12 @@ class FakeWebSocket implements MattermostWebSocketLike {
}
}
emitPong(data = Buffer.alloc(0)): void {
for (const listener of this.pongListeners) {
listener(data);
}
}
emitClose(code: number, reason = ""): void {
const buffer = Buffer.from(reason, "utf8");
for (const listener of this.closeListeners) {
@@ -282,6 +299,82 @@ describe("mattermost websocket monitor", () => {
vi.useRealTimers();
});
it("continues protocol keepalive when Mattermost responds with pong", async () => {
vi.useFakeTimers();
const socket = new FakeWebSocket();
const connectOnce = createMattermostConnectOnce({
wsUrl: "wss://example.invalid/api/v4/websocket",
botToken: "token",
runtime: testRuntime(),
nextSeq: () => 1,
onPosted: async () => {},
webSocketFactory: () => socket,
pingIntervalMs: 100,
pongTimeoutMs: 25,
});
const connected = connectOnce();
socket.emitOpen();
await vi.advanceTimersByTimeAsync(100);
expect(socket.pingCalls).toBe(1);
socket.emitPong();
await vi.advanceTimersByTimeAsync(25);
expect(socket.terminateCalls).toBe(0);
await vi.advanceTimersByTimeAsync(75);
expect(socket.pingCalls).toBe(2);
socket.emitClose(1000);
await connected;
vi.useRealTimers();
});
it("terminates silent websocket drops when Mattermost misses pong timeout", async () => {
vi.useFakeTimers();
const socket = new FakeWebSocket();
const runtime = testRuntime();
let pollCount = 0;
const connectOnce = createMattermostConnectOnce({
wsUrl: "wss://example.invalid/api/v4/websocket",
botToken: "token",
runtime,
nextSeq: () => 1,
onPosted: async () => {},
webSocketFactory: () => socket,
getBotUpdateAt: async () => {
pollCount++;
return 1000;
},
healthCheckIntervalMs: 100,
pingIntervalMs: 50,
pongTimeoutMs: 25,
});
const connected = connectOnce();
socket.emitOpen();
await vi.advanceTimersByTimeAsync(0);
expect(pollCount).toBe(1);
await vi.advanceTimersByTimeAsync(50);
expect(socket.pingCalls).toBe(1);
expect(socket.terminateCalls).toBe(0);
await vi.advanceTimersByTimeAsync(25);
expect(socket.terminateCalls).toBe(1);
expect(runtime.error).toHaveBeenCalledWith("mattermost websocket pong timeout — reconnecting");
await vi.advanceTimersByTimeAsync(500);
expect(socket.pingCalls).toBe(1);
expect(pollCount).toBe(1);
socket.emitClose(1006);
await connected;
vi.useRealTimers();
});
it("does not terminate when getBotUpdateAt throws", async () => {
vi.useFakeTimers();
const socket = new FakeWebSocket();

View File

@@ -33,8 +33,10 @@ export type MattermostEventPayload = {
export type MattermostWebSocketLike = {
on(event: "open", listener: () => void): void;
on(event: "message", listener: (data: WebSocket.RawData) => void | Promise<void>): void;
on(event: "pong", listener: (data: Buffer) => void): void;
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
on(event: "error", listener: (err: unknown) => void): void;
ping(): void;
send(data: string): void;
close(): void;
terminate(): void;
@@ -104,6 +106,8 @@ type CreateMattermostConnectOnceOpts = {
*/
getBotUpdateAt?: () => Promise<number>;
healthCheckIntervalMs?: number;
pingIntervalMs?: number;
pongTimeoutMs?: number;
};
export const defaultMattermostWebSocketFactory: MattermostWebSocketFactory = (url) => {
@@ -144,6 +148,8 @@ export function createMattermostConnectOnce(
): () => Promise<void> {
const webSocketFactory = opts.webSocketFactory ?? defaultMattermostWebSocketFactory;
const healthCheckIntervalMs = opts.healthCheckIntervalMs ?? 30_000;
const pingIntervalMs = opts.pingIntervalMs ?? 30_000;
const pongTimeoutMs = opts.pongTimeoutMs ?? 10_000;
return async () => {
const flowId = randomUUID();
const ws = webSocketFactory(opts.wsUrl);
@@ -158,6 +164,9 @@ export function createMattermostConnectOnce(
let healthCheckEnabled = getBotUpdateAt != null;
let healthCheckInFlight = false;
let healthCheckTimer: ReturnType<typeof setTimeout> | undefined;
let protocolKeepaliveEnabled = true;
let protocolPingTimer: ReturnType<typeof setTimeout> | undefined;
let protocolPongTimer: ReturnType<typeof setTimeout> | undefined;
let initialUpdateAt: number | undefined;
const clearTimers = () => {
@@ -165,13 +174,60 @@ export function createMattermostConnectOnce(
clearTimeout(healthCheckTimer);
healthCheckTimer = undefined;
}
if (protocolPingTimer !== undefined) {
clearTimeout(protocolPingTimer);
protocolPingTimer = undefined;
}
if (protocolPongTimer !== undefined) {
clearTimeout(protocolPongTimer);
protocolPongTimer = undefined;
}
};
const stopHealthChecks = () => {
healthCheckEnabled = false;
protocolKeepaliveEnabled = false;
clearTimers();
};
const sendProtocolPing = () => {
if (!protocolKeepaliveEnabled || settled) {
return;
}
if (protocolPongTimer !== undefined) {
clearTimeout(protocolPongTimer);
}
protocolPongTimer = setTimeout(() => {
protocolPongTimer = undefined;
if (!protocolKeepaliveEnabled || settled) {
return;
}
opts.runtime.error?.("mattermost websocket pong timeout — reconnecting");
stopHealthChecks();
ws.terminate();
}, pongTimeoutMs);
try {
ws.ping();
} catch (err) {
if (!protocolKeepaliveEnabled || settled) {
return;
}
opts.runtime.error?.(`mattermost websocket ping failed: ${String(err)}`);
stopHealthChecks();
ws.terminate();
}
};
const scheduleProtocolPing = () => {
if (!protocolKeepaliveEnabled || settled || protocolPingTimer !== undefined) {
return;
}
protocolPingTimer = setTimeout(() => {
protocolPingTimer = undefined;
sendProtocolPing();
}, pingIntervalMs);
};
const scheduleHealthCheck = () => {
if (!getBotUpdateAt || !healthCheckEnabled || settled || healthCheckInFlight) {
return;
@@ -263,6 +319,7 @@ export function createMattermostConnectOnce(
meta: { subsystem: "mattermost-websocket", eventType: "authentication_challenge" },
});
ws.send(authPayload);
scheduleProtocolPing();
// Periodically check if the bot account was modified (e.g. disable/enable).
// After such a cycle the WebSocket silently stops delivering events even
@@ -274,6 +331,14 @@ export function createMattermostConnectOnce(
}
});
ws.on("pong", () => {
if (protocolPongTimer !== undefined) {
clearTimeout(protocolPongTimer);
protocolPongTimer = undefined;
}
scheduleProtocolPing();
});
ws.on("message", async (data) => {
captureWsEvent({
url: opts.wsUrl,

View File

@@ -5,14 +5,16 @@ class FakeWebSocket {
public readonly sent: string[] = [];
private readonly openListeners: Array<() => void> = [];
private readonly messageListeners: Array<(data: Buffer) => void | Promise<void>> = [];
private readonly pongListeners: Array<(data: Buffer) => void> = [];
private readonly closeListeners: Array<(code: number, reason: Buffer) => void> = [];
private readonly errorListeners: Array<(err: unknown) => void> = [];
on(event: "open", listener: () => void): void;
on(event: "message", listener: (data: Buffer) => void | Promise<void>): void;
on(event: "pong", listener: (data: Buffer) => void): void;
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
on(event: "error", listener: (err: unknown) => void): void;
on(event: "open" | "message" | "close" | "error", listener: unknown): void {
on(event: "open" | "message" | "pong" | "close" | "error", listener: unknown): void {
if (event === "open") {
this.openListeners.push(listener as () => void);
return;
@@ -21,6 +23,10 @@ class FakeWebSocket {
this.messageListeners.push(listener as (data: Buffer) => void | Promise<void>);
return;
}
if (event === "pong") {
this.pongListeners.push(listener as (data: Buffer) => void);
return;
}
if (event === "close") {
this.closeListeners.push(listener as (code: number, reason: Buffer) => void);
return;
@@ -32,6 +38,8 @@ class FakeWebSocket {
this.sent.push(data);
}
ping(): void {}
close(): void {}
terminate(): void {}
@@ -168,6 +176,27 @@ function createRuntimeCore(cfg: OpenClawConfig) {
};
},
);
const run = vi.fn(
async (params: {
raw: unknown;
adapter: {
ingest: (raw: unknown) => unknown;
resolveTurn: (
input: unknown,
eventClass: { kind: "message"; canStartAgentTurn: true },
preflight: Record<string, never>,
) => Parameters<typeof runPrepared>[0];
};
}) => {
const input = params.adapter.ingest(params.raw);
const turn = params.adapter.resolveTurn(
input,
{ kind: "message", canStartAgentTurn: true },
{},
);
return await runPrepared(turn);
},
);
return {
config: {
current: () => cfg,
@@ -252,6 +281,7 @@ function createRuntimeCore(cfg: OpenClawConfig) {
updateLastRoute: vi.fn(async () => {}),
},
turn: {
run,
runPrepared,
},
text: {

View File

@@ -69,7 +69,6 @@ import {
buildAgentMediaPayload,
buildModelsProviderData,
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
createChannelPairingController,
createChannelReplyPipeline,
DEFAULT_GROUP_HISTORY_LIMIT,
@@ -1721,74 +1720,95 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
let dispatchSettledBeforeStart = false;
try {
await core.channel.turn.runPrepared({
await core.channel.turn.run({
channel: "mattermost",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
updateLastRoute:
kind === "direct"
? {
sessionKey: route.mainSessionKey,
channel: "mattermost",
to,
accountId: route.accountId,
}
: undefined,
onRecordError: (err) => {
logVerboseMessage(
`mattermost: failed updating session meta id=${post.id ?? "unknown"}: ${String(err)}`,
);
},
},
onPreDispatchFailure: async () => {
dispatchSettledBeforeStart = true;
await core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => {
markRunComplete();
markDispatchIdle();
},
});
},
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
disableBlockStreaming: true,
onModelSelected,
onPartialReply: (payload) => {
updateDraftFromPartial(payload.text);
},
onAssistantMessageStart: () => {
lastPartialText = "";
},
onReasoningEnd: () => {
lastPartialText = "";
},
onReasoningStream: async () => {
if (!lastPartialText) {
draftStream.update("Thinking…");
raw: post,
adapter: {
ingest: () => ({
id: post.id ?? `${to}:${Date.now()}`,
timestamp: post.create_at ?? undefined,
rawText,
textForAgent: ctxPayload.BodyForAgent,
textForCommands: ctxPayload.CommandBody,
raw: post,
}),
resolveTurn: () => ({
channel: "mattermost",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
updateLastRoute:
kind === "direct"
? {
sessionKey: route.mainSessionKey,
channel: "mattermost",
to,
accountId: route.accountId,
}
},
onToolStart: async (payload) => {
draftStream.update(buildMattermostToolStatusText(payload));
},
: undefined,
onRecordError: (err) => {
logVerboseMessage(
`mattermost: failed updating session meta id=${post.id ?? "unknown"}: ${String(err)}`,
);
},
},
history: {
isGroup: Boolean(historyKey),
historyKey: historyKey ?? undefined,
historyMap: channelHistories,
limit: historyLimit,
},
onPreDispatchFailure: async () => {
dispatchSettledBeforeStart = true;
await core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => {
markRunComplete();
markDispatchIdle();
},
});
},
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
disableBlockStreaming: true,
onModelSelected,
onPartialReply: (payload) => {
updateDraftFromPartial(payload.text);
},
onAssistantMessageStart: () => {
lastPartialText = "";
},
onReasoningEnd: () => {
lastPartialText = "";
},
onReasoningStream: async () => {
if (!lastPartialText) {
draftStream.update("Thinking…");
}
},
onToolStart: async (payload) => {
draftStream.update(buildMattermostToolStatusText(payload));
},
},
}),
}),
}),
},
});
} finally {
try {
@@ -1800,13 +1820,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
markRunComplete();
}
}
if (historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: channelHistories,
historyKey,
limit: historyLimit,
});
}
},
});
if (replayResult === "duplicate") {

View File

@@ -2160,6 +2160,101 @@ describe("memory plugin e2e", () => {
expect(detectCategory("The server is running on port 3000")).toBe("fact");
expect(detectCategory("Random note")).toBe("other");
});
test("memory_forget candidate list shows full UUIDs, not truncated IDs", async () => {
const fakeUuid1 = "890e1fae-1234-5678-abcd-ef0123456789";
const fakeUuid2 = "a1b2c3d4-5678-9abc-def0-1234567890ab";
// LanceDB vectorSearch returns rows with _distance; score = 1/(1+d)
// We want scores between 0.7 and 0.9 so candidates are returned (not auto-deleted)
// score=0.85 => d = 1/0.85 - 1 ≈ 0.176; score=0.80 => d = 1/0.80 - 1 = 0.25
const fakeRows = [
{
id: fakeUuid1,
text: "User prefers dark mode",
category: "preference",
vector: [0.1],
importance: 0.8,
createdAt: Date.now(),
_distance: 0.176,
},
{
id: fakeUuid2,
text: "User lives in New York",
category: "fact",
vector: [0.2],
importance: 0.7,
createdAt: Date.now(),
_distance: 0.25,
},
];
const toArray = vi.fn(async () => fakeRows);
const limitFn = vi.fn(() => ({ toArray }));
const vectorSearch = vi.fn(() => ({ limit: limitFn }));
vi.resetModules();
vi.doMock("openai", () => ({
default: class MockOpenAI {
post = vi.fn(async () => ({ data: [{ embedding: [0.1, 0.2, 0.3] }] }));
},
}));
vi.doMock("@lancedb/lancedb", () => ({
connect: vi.fn(async () => ({
tableNames: vi.fn(async () => ["memories"]),
openTable: vi.fn(async () => ({
vectorSearch,
countRows: vi.fn(async () => 2),
add: vi.fn(async () => undefined),
delete: vi.fn(async () => undefined),
})),
})),
}));
try {
const { default: memoryPlugin } = await import("./index.js");
const registeredTools: any[] = [];
const mockApi = {
id: "memory-lancedb",
name: "Memory (LanceDB)",
source: "test",
config: {},
pluginConfig: {
embedding: { apiKey: OPENAI_API_KEY, model: "text-embedding-3-small" },
dbPath: getDbPath(),
autoCapture: false,
autoRecall: false,
},
runtime: {},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
registerTool: (tool: any, opts: any) => {
registeredTools.push({ tool, opts });
},
registerCli: vi.fn(),
registerService: vi.fn(),
on: vi.fn(),
resolvePath: (p: string) => p,
};
memoryPlugin.register(mockApi as any);
const forgetTool = registeredTools.find((t) => t.opts?.name === "memory_forget")?.tool;
expect(forgetTool).toBeDefined();
const result = await forgetTool.execute("test-call-full-ids", { query: "user preference" });
// The candidate list text must contain the FULL UUID, not a truncated prefix
const text = result.content?.[0]?.text ?? "";
expect(text).toContain(fakeUuid1);
expect(text).toContain(fakeUuid2);
// Ensure truncated 8-char prefix alone is NOT the format used
expect(text).not.toMatch(/\[890e1fae\]/);
expect(text).not.toMatch(/\[a1b2c3d4\]/);
} finally {
vi.doUnmock("openai");
vi.doUnmock("@lancedb/lancedb");
vi.resetModules();
}
});
});
describe("lancedb runtime loader", () => {

View File

@@ -755,7 +755,7 @@ export default definePluginEntry({
}
const list = results
.map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}...`)
.map((r) => `- [${r.entry.id}] ${r.entry.text.slice(0, 60)}...`)
.join("\n");
// Strip vector data for serialization

View File

@@ -40,6 +40,26 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
};
},
);
const run = vi.fn(async (params: Parameters<PluginRuntime["channel"]["turn"]["run"]>[0]) => {
const input = await params.adapter.ingest(params.raw);
if (!input) {
return { admission: { kind: "drop" as const, reason: "ingest-null" }, dispatched: false };
}
const eventClass = (await params.adapter.classify?.(input)) ?? {
kind: "message" as const,
canStartAgentTurn: true,
};
const preflightResult = await params.adapter.preflight?.(input, eventClass);
const preflight =
preflightResult && "kind" in preflightResult
? { admission: preflightResult }
: (preflightResult ?? {});
const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
if ("runDispatch" in turn) {
return await runPrepared(turn);
}
throw new Error("msteams test runtime only supports prepared turn dispatch");
});
setMSTeamsRuntime({
logging: { shouldLogVerbose: () => false },
system: { enqueueSystemEvent: options.enqueueSystemEvent ?? vi.fn() },
@@ -90,6 +110,7 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
...(options.resolveStorePath ? { resolveStorePath: options.resolveStorePath } : {}),
},
turn: {
run: run as unknown as PluginRuntime["channel"]["turn"]["run"],
runPrepared: runPrepared as unknown as PluginRuntime["channel"]["turn"]["runPrepared"],
},
},

View File

@@ -18,7 +18,6 @@ import {
} from "openclaw/plugin-sdk/inbound-reply-dispatch";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
@@ -840,33 +839,57 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
log.info("dispatching to agent", { sessionKey: route.sessionKey });
try {
const { dispatchResult } = await core.channel.turn.runPrepared({
const turnResult = await core.channel.turn.run({
channel: "msteams",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
onRecordError: (err) => {
logVerboseMessage(`msteams: failed updating session meta: ${formatUnknownError(err)}`);
},
},
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
raw: context,
adapter: {
ingest: () => ({
id: activity.id ?? `${teamsFrom}:${Date.now()}`,
timestamp: timestamp?.getTime(),
rawText: rawBody,
textForAgent: bodyForAgent,
textForCommands: commandBody,
raw: activity,
}),
runDispatch: () =>
dispatchReplyFromConfigWithSettledDispatcher({
cfg,
resolveTurn: () => ({
channel: "msteams",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
dispatcher,
onSettled: () => markDispatchIdle(),
replyOptions,
configOverride,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
onRecordError: (err) => {
logVerboseMessage(
`msteams: failed updating session meta: ${formatUnknownError(err)}`,
);
},
},
history: {
isGroup: isRoomish,
historyKey,
historyMap: conversationHistories,
limit: historyLimit,
},
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
}),
runDispatch: () =>
dispatchReplyFromConfigWithSettledDispatcher({
cfg,
ctxPayload,
dispatcher,
onSettled: () => markDispatchIdle(),
replyOptions,
configOverride,
}),
}),
},
});
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
const queuedFinal = dispatchResult?.queuedFinal ?? false;
const counts = resolveInboundReplyDispatchCounts(dispatchResult);
const hasFinalResponse = hasFinalInboundReplyDispatch(dispatchResult);
@@ -874,26 +897,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
log.info("dispatch complete", { queuedFinal, counts });
if (!hasFinalResponse) {
if (isRoomish && historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
});
}
return;
}
const finalCount = counts.final;
logVerboseMessage(
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
);
if (isRoomish && historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
});
}
} catch (err) {
log.error("dispatch failed", { error: formatUnknownError(err) });
runtime.error?.(`msteams dispatch failed: ${formatUnknownError(err)}`);

View File

@@ -69,9 +69,24 @@ function makeRuntime(): GatewayPluginRuntime {
recordInboundSession: vi.fn(async () => undefined),
},
turn: {
runPrepared: vi.fn(async (rawParams: unknown) => {
const params = rawParams as { runDispatch: () => Promise<unknown> };
return { dispatchResult: await params.runDispatch() };
run: vi.fn(async (rawParams: unknown) => {
const params = rawParams as {
raw: unknown;
adapter: {
ingest: (raw: unknown) => unknown;
resolveTurn: (...args: unknown[]) => unknown;
};
};
const input = await params.adapter.ingest(params.raw);
const turn = (await params.adapter.resolveTurn(
input,
{
kind: "message",
canStartAgentTurn: true,
},
{},
)) as { runDispatch: () => Promise<unknown> };
return { dispatchResult: await turn.runDispatch() };
}),
},
text: {

View File

@@ -141,9 +141,24 @@ function makeRuntime(params: {
recordInboundSession: vi.fn(async () => undefined),
},
turn: {
runPrepared: vi.fn(async (rawParams: unknown) => {
const params = rawParams as { runDispatch: () => Promise<unknown> };
return { dispatchResult: await params.runDispatch() };
run: vi.fn(async (rawParams: unknown) => {
const params = rawParams as {
raw: unknown;
adapter: {
ingest: (raw: unknown) => unknown;
resolveTurn: (...args: unknown[]) => unknown;
};
};
const input = await params.adapter.ingest(params.raw);
const turn = (await params.adapter.resolveTurn(
input,
{
kind: "message",
canStartAgentTurn: true,
},
{},
)) as { runDispatch: () => Promise<unknown> };
return { dispatchResult: await turn.runDispatch() };
}),
},
text: {

View File

@@ -10,6 +10,7 @@
* Separated from gateway.ts for testability and to keep handleMessage thin.
*/
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
import {
parseAndSendMediaTags,
sendPlainReply,
@@ -224,238 +225,256 @@ export async function dispatchOutbound(
const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, {
agentId,
});
const dispatchPromise = runtime.channel.turn.runPrepared({
const dispatchPromise = runtime.channel.turn.run({
channel: "qqbot",
accountId: inbound.route.accountId,
routeSessionKey: inbound.route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
record: {
onRecordError: (err: unknown) => {
log?.error(
`Session metadata update failed: ${err instanceof Error ? err.message : String(err)}`,
);
},
},
runDispatch: () =>
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix: messagesConfig.responsePrefix,
deliver: async (payload: ReplyDeliverPayload, info: { kind: string }) => {
hasResponse = true;
raw: inbound,
adapter: {
ingest: () => ({
id: ctxPayload.MessageSid ?? `${ctxPayload.From}:${Date.now()}`,
rawText: ctxPayload.RawBody ?? "",
textForAgent: ctxPayload.BodyForAgent,
textForCommands: ctxPayload.CommandBody,
raw: inbound,
}),
resolveTurn: () => ({
channel: "qqbot",
accountId: inbound.route.accountId,
routeSessionKey: inbound.route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
record: {
onRecordError: (err: unknown) => {
log?.error(
`Session metadata update failed: ${err instanceof Error ? err.message : String(err)}`,
);
},
},
runDispatch: () =>
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix: messagesConfig.responsePrefix,
deliver: async (payload: ReplyDeliverPayload, info: { kind: string }) => {
hasResponse = true;
// ---- Tool deliver ----
if (info.kind === "tool") {
toolDeliverCount++;
const toolText = (payload.text ?? "").trim();
if (toolText) {
toolTexts.push(toolText);
}
if (payload.mediaUrls?.length) {
toolMediaUrls.push(...payload.mediaUrls);
}
if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
toolMediaUrls.push(payload.mediaUrl);
}
// ---- Tool deliver ----
if (info.kind === "tool") {
toolDeliverCount++;
const toolText = (payload.text ?? "").trim();
if (toolText) {
toolTexts.push(toolText);
}
if (payload.mediaUrls?.length) {
toolMediaUrls.push(...payload.mediaUrls);
}
if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
toolMediaUrls.push(payload.mediaUrl);
}
if (hasBlockResponse && toolMediaUrls.length > 0) {
const urlsToSend = [...toolMediaUrls];
toolMediaUrls.length = 0;
for (const mediaUrl of urlsToSend) {
try {
await sendMedia({
to: qualifiedTarget,
text: "",
mediaUrl,
accountId: account.accountId,
replyToId: event.messageId,
account,
});
} catch {}
}
return;
}
if (toolFallbackSent) {
return;
}
if (toolOnlyTimeoutId) {
if (toolRenewalCount < MAX_TOOL_RENEWALS) {
clearTimeout(toolOnlyTimeoutId);
toolRenewalCount++;
} else {
if (hasBlockResponse && toolMediaUrls.length > 0) {
const urlsToSend = [...toolMediaUrls];
toolMediaUrls.length = 0;
for (const mediaUrl of urlsToSend) {
try {
await sendMedia({
to: qualifiedTarget,
text: "",
mediaUrl,
accountId: account.accountId,
replyToId: event.messageId,
account,
});
} catch {}
}
return;
}
if (toolFallbackSent) {
return;
}
if (toolOnlyTimeoutId) {
if (toolRenewalCount < MAX_TOOL_RENEWALS) {
clearTimeout(toolOnlyTimeoutId);
toolRenewalCount++;
} else {
return;
}
}
toolOnlyTimeoutId = setTimeout(async () => {
if (!hasBlockResponse && !toolFallbackSent) {
toolFallbackSent = true;
try {
await sendToolFallback();
} catch {}
}
}, TOOL_ONLY_TIMEOUT);
return;
}
}
toolOnlyTimeoutId = setTimeout(async () => {
if (!hasBlockResponse && !toolFallbackSent) {
toolFallbackSent = true;
try {
await sendToolFallback();
} catch {}
// ---- Block deliver ----
hasBlockResponse = true;
inbound.typing.keepAlive?.stop();
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
}, TOOL_ONLY_TIMEOUT);
return;
}
// ---- Block deliver ----
hasBlockResponse = true;
inbound.typing.keepAlive?.stop();
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (toolOnlyTimeoutId) {
clearTimeout(toolOnlyTimeoutId);
toolOnlyTimeoutId = null;
}
if (streamingController && !streamingController.isTerminalPhase) {
try {
await streamingController.onDeliver(payload);
} catch (err) {
log?.error(
`Streaming deliver error: ${err instanceof Error ? err.message : String(err)}`,
);
}
const replyPreview = (payload.text ?? "").trim();
if (
event.type === "group" &&
(replyPreview === "NO_REPLY" || replyPreview === "[SKIP]")
) {
log?.info(
`Model decided to skip group message (${replyPreview}) from ${event.senderId}`,
);
return;
}
if (streamingController.shouldFallbackToStatic) {
log?.info("Streaming API unavailable, falling back to static for this deliver");
} else {
recordOutbound();
return;
}
}
const quoteRef = event.msgIdx;
let quoteRefUsed = false;
const consumeQuoteRef = (): string | undefined => {
if (quoteRef && !quoteRefUsed) {
quoteRefUsed = true;
return quoteRef;
}
return undefined;
};
let replyText = payload.text ?? "";
const deliverEvent = {
type: event.type,
senderId: event.senderId,
messageId: event.messageId,
channelId: event.channelId,
groupOpenid: event.groupOpenid,
msgIdx: event.msgIdx,
};
const deliverActx = { account, qualifiedTarget, log };
// 1. Media tags
const mediaResult = await parseAndSendMediaTags(
replyText,
deliverEvent,
deliverActx,
sendWithRetry,
consumeQuoteRef,
deliverDeps,
);
if (mediaResult.handled) {
recordOutbound();
return;
}
replyText = mediaResult.normalizedText;
// 2. Structured payload (QQBOT_PAYLOAD:)
const handled = await handleStructuredPayload(
replyCtx,
replyText,
recordOutbound,
replyDeps,
);
if (handled) {
return;
}
// 3. Voice-intent plain text
if (payload.audioAsVoice === true && !payload.mediaUrl && !payload.mediaUrls?.length) {
const sentVoice = await sendTextAsVoiceReply(replyCtx, replyText, replyDeps);
if (sentVoice) {
recordOutbound();
return;
}
}
// 4. Plain text + images/media
await sendPlainReply(
payload,
replyText,
deliverEvent,
deliverActx,
sendWithRetry,
consumeQuoteRef,
toolMediaUrls,
deliverDeps,
);
recordOutbound();
},
onError: async (err: unknown) => {
if (streamingController && !streamingController.isTerminalPhase) {
try {
await streamingController.onError(err);
} catch (streamErr) {
const streamErrMsg =
streamErr instanceof Error ? streamErr.message : String(streamErr);
log?.error(`Streaming onError failed: ${streamErrMsg}`);
}
if (!streamingController.shouldFallbackToStatic) {
return;
}
}
const errMsg = err instanceof Error ? err.message : String(err);
log?.error(`Dispatch error: ${errMsg}`);
hasResponse = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
},
},
replyOptions: {
disableBlockStreaming: useOfficialC2cStream
? true
: (() => {
const s = account.config?.streaming;
if (s === false) {
return true;
if (toolOnlyTimeoutId) {
clearTimeout(toolOnlyTimeoutId);
toolOnlyTimeoutId = null;
}
return typeof s === "object" && s !== null && s.mode === "off";
})(),
...(streamingController
? {
onPartialReply: async (payload: { text?: string }) => {
if (streamingController && !streamingController.isTerminalPhase) {
try {
await streamingController.onPartialReply(payload);
} catch (partialErr) {
await streamingController.onDeliver(payload);
} catch (err) {
log?.error(
`Streaming onPartialReply error: ${partialErr instanceof Error ? partialErr.message : String(partialErr)}`,
`Streaming deliver error: ${err instanceof Error ? err.message : String(err)}`,
);
}
},
}
: {}),
},
const replyPreview = (payload.text ?? "").trim();
if (
event.type === "group" &&
(replyPreview === "NO_REPLY" || replyPreview === "[SKIP]")
) {
log?.info(
`Model decided to skip group message (${replyPreview}) from ${event.senderId}`,
);
return;
}
if (streamingController.shouldFallbackToStatic) {
log?.info("Streaming API unavailable, falling back to static for this deliver");
} else {
recordOutbound();
return;
}
}
const quoteRef = event.msgIdx;
let quoteRefUsed = false;
const consumeQuoteRef = (): string | undefined => {
if (quoteRef && !quoteRefUsed) {
quoteRefUsed = true;
return quoteRef;
}
return undefined;
};
let replyText = payload.text ?? "";
const deliverEvent = {
type: event.type,
senderId: event.senderId,
messageId: event.messageId,
channelId: event.channelId,
groupOpenid: event.groupOpenid,
msgIdx: event.msgIdx,
};
const deliverActx = { account, qualifiedTarget, log };
// 1. Media tags
const mediaResult = await parseAndSendMediaTags(
replyText,
deliverEvent,
deliverActx,
sendWithRetry,
consumeQuoteRef,
deliverDeps,
);
if (mediaResult.handled) {
recordOutbound();
return;
}
replyText = mediaResult.normalizedText;
// 2. Structured payload (QQBOT_PAYLOAD:)
const handled = await handleStructuredPayload(
replyCtx,
replyText,
recordOutbound,
replyDeps,
);
if (handled) {
return;
}
// 3. Voice-intent plain text
if (
payload.audioAsVoice === true &&
!payload.mediaUrl &&
!payload.mediaUrls?.length
) {
const sentVoice = await sendTextAsVoiceReply(replyCtx, replyText, replyDeps);
if (sentVoice) {
recordOutbound();
return;
}
}
// 4. Plain text + images/media
await sendPlainReply(
payload,
replyText,
deliverEvent,
deliverActx,
sendWithRetry,
consumeQuoteRef,
toolMediaUrls,
deliverDeps,
);
recordOutbound();
},
onError: async (err: unknown) => {
if (streamingController && !streamingController.isTerminalPhase) {
try {
await streamingController.onError(err);
} catch (streamErr) {
const streamErrMsg =
streamErr instanceof Error ? streamErr.message : String(streamErr);
log?.error(`Streaming onError failed: ${streamErrMsg}`);
}
if (!streamingController.shouldFallbackToStatic) {
return;
}
}
const errMsg = err instanceof Error ? err.message : String(err);
log?.error(`Dispatch error: ${errMsg}`);
hasResponse = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
},
},
replyOptions: {
disableBlockStreaming: useOfficialC2cStream
? true
: (() => {
const s = account.config?.streaming;
if (s === false) {
return true;
}
return typeof s === "object" && s !== null && s.mode === "off";
})(),
...(streamingController
? {
onPartialReply: async (payload: { text?: string }) => {
try {
await streamingController.onPartialReply(payload);
} catch (partialErr) {
log?.error(
`Streaming onPartialReply error: ${partialErr instanceof Error ? partialErr.message : String(partialErr)}`,
);
}
},
}
: {}),
},
}),
}),
},
});
try {
@@ -493,7 +512,10 @@ export async function dispatchOutbound(
// ============ ctxPayload builder ============
function buildCtxPayload(inbound: InboundContext, runtime: GatewayPluginRuntime): unknown {
function buildCtxPayload(
inbound: InboundContext,
runtime: GatewayPluginRuntime,
): FinalizedMsgContext {
const { event } = inbound;
return runtime.channel.reply.finalizeInboundContext({
Body: inbound.body,
@@ -549,5 +571,5 @@ function buildCtxPayload(inbound: InboundContext, runtime: GatewayPluginRuntime)
ReplyToIsQuote: inbound.replyTo.isQuote,
}
: {}),
});
}) as FinalizedMsgContext;
}

View File

@@ -57,7 +57,7 @@ export interface GatewayPluginRuntime {
recordInboundSession: (params: unknown) => Promise<unknown>;
};
turn: {
runPrepared: (params: unknown) => Promise<unknown>;
run: (params: unknown) => Promise<unknown>;
};
text: {
chunkMarkdownText: (text: string, limit: number) => string[];

View File

@@ -25,7 +25,7 @@ import {
toInternalMessageReceivedContext,
triggerInternalHook,
} from "openclaw/plugin-sdk/hook-runtime";
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { runInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import {
buildPendingHistoryContextFromMap,
@@ -288,72 +288,85 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
},
});
await runPreparedInboundReplyTurn({
await runInboundReplyTurn({
channel: "signal",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession,
record: {
updateLastRoute: !entry.isGroup
? {
sessionKey: route.mainSessionKey,
channel: "signal",
to: entry.senderRecipient,
accountId: route.accountId,
mainDmOwnerPin: (() => {
const pinnedOwner = resolvePinnedMainDmOwnerFromAllowlist({
dmScope: deps.cfg.session?.dmScope,
allowFrom: deps.allowFrom,
normalizeEntry: normalizeSignalAllowRecipient,
});
if (!pinnedOwner) {
return undefined;
}
return {
ownerRecipient: pinnedOwner,
senderRecipient: entry.senderRecipient,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`signal: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
};
})(),
}
: undefined,
onRecordError: (err) => {
logVerbose(`signal: failed updating session meta: ${String(err)}`);
},
},
history: {
isGroup: entry.isGroup,
historyKey,
historyMap: deps.groupHistories,
limit: deps.historyLimit,
},
onPreDispatchFailure: () =>
settleReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
raw: entry,
adapter: {
ingest: () => ({
id: entry.messageId ?? `${entry.timestamp ?? Date.now()}`,
timestamp: entry.timestamp,
rawText: entry.bodyText,
raw: entry,
}),
runDispatch: async () => {
try {
return await dispatchInboundMessage({
ctx: ctxPayload,
cfg: deps.cfg,
dispatcher,
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
onModelSelected,
resolveTurn: () => ({
channel: "signal",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession,
record: {
updateLastRoute: !entry.isGroup
? {
sessionKey: route.mainSessionKey,
channel: "signal",
to: entry.senderRecipient,
accountId: route.accountId,
mainDmOwnerPin: (() => {
const pinnedOwner = resolvePinnedMainDmOwnerFromAllowlist({
dmScope: deps.cfg.session?.dmScope,
allowFrom: deps.allowFrom,
normalizeEntry: normalizeSignalAllowRecipient,
});
if (!pinnedOwner) {
return undefined;
}
return {
ownerRecipient: pinnedOwner,
senderRecipient: entry.senderRecipient,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`signal: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
};
})(),
}
: undefined,
onRecordError: (err) => {
logVerbose(`signal: failed updating session meta: ${String(err)}`);
},
});
} finally {
markDispatchIdle();
}
},
history: {
isGroup: entry.isGroup,
historyKey,
historyMap: deps.groupHistories,
limit: deps.historyLimit,
},
onPreDispatchFailure: () =>
settleReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
}),
runDispatch: async () => {
try {
return await dispatchInboundMessage({
ctx: ctxPayload,
cfg: deps.cfg,
dispatcher,
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
onModelSelected,
},
});
} finally {
markDispatchIdle();
}
},
}),
},
});
}

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