Compare commits

..

51 Commits

Author SHA1 Message Date
Peter Steinberger
9d3394b459 fix(cron): use --session-target and --session flags (#27167) (thanks @Matt-Hulme) 2026-02-26 13:27:46 +01:00
Peter Steinberger
912b55ac18 fix(config): harden include file loading path checks 2026-02-26 12:53:44 +01:00
Peter Steinberger
9560d8cf9a chore: bump versions to 2026.2.26 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
d50e2677b6 chore(acpx): bump package version to 2026.2.25 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
a043668ba4 docs(changelog): thank @emanuelst for telegram preview fix (#27449) 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
0d020469e4 fix(telegram): prime final preview before stop flush 2026-02-26 12:53:44 +01:00
Gustavo Madeira Santana
5be223ec1c Tests: tighten discord work account type in doctor config flow 2026-02-26 12:53:44 +01:00
Gustavo Madeira Santana
9426abbccb Doctor: keep allowFrom account-scoped in multi-account configs 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
2963f49659 fix: changelog for NO_REPLY streaming fix (#19576) (thanks @aldoeliacim) 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
37b8606e48 docs(auto-reply): align silent token comment with regex 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
2620f30500 fix(auto-reply): tighten silent token semantics and prefix streaming 2026-02-26 12:53:44 +01:00
HAL
f7b031f2dd fix: tighten isSilentReplyText to match whole-text only
The suffix regex matched NO_REPLY at the end of any response,
suppressing substantive replies when models (e.g. Gemini 3 Pro)
appended NO_REPLY to real content.

Replace prefix+suffix regexes with a single whole-string match.
Only responses that are entirely the silent token (with optional
whitespace) are now suppressed.

Add unit tests for the fix.

Fixes #19537
2026-02-26 12:53:44 +01:00
Onur Solmaz
57e7299e20 feat: ACP thread-bound agents (#23580)
* docs: add ACP thread-bound agents plan doc

* docs: expand ACP implementation specification

* feat(acp): route ACP sessions through core dispatch and lifecycle cleanup

* feat(acp): add /acp commands and Discord spawn gate

* ACP: add acpx runtime plugin backend

* fix(subagents): defer transient lifecycle errors before announce

* Agents: harden ACP sessions_spawn and tighten spawn guidance

* Agents: require explicit ACP target for runtime spawns

* docs: expand ACP control-plane implementation plan

* ACP: harden metadata seeding and spawn guidance

* ACP: centralize runtime control-plane manager and fail-closed dispatch

* ACP: harden runtime manager and unify spawn helpers

* Commands: route ACP sessions through ACP runtime in agent command

* ACP: require persisted metadata for runtime spawns

* Sessions: preserve ACP metadata when updating entries

* Plugins: harden ACP backend registry across loaders

* ACPX: make availability probe compatible with adapters

* E2E: add manual Discord ACP plain-language smoke script

* ACPX: preserve streamed spacing across Discord delivery

* Docs: add ACP Discord streaming strategy

* ACP: harden Discord stream buffering for thread replies

* ACP: reuse shared block reply pipeline for projector

* ACP: unify streaming config and adopt coalesceIdleMs

* Docs: add temporary ACP production hardening plan

* Docs: trim temporary ACP hardening plan goals

* Docs: gate ACP thread controls by backend capabilities

* ACP: add capability-gated runtime controls and /acp operator commands

* Docs: remove temporary ACP hardening plan

* ACP: fix spawn target validation and close cache cleanup

* ACP: harden runtime dispatch and recovery paths

* ACP: split ACP command/runtime internals and centralize policy

* ACP: harden runtime lifecycle, validation, and observability

* ACP: surface runtime and backend session IDs in thread bindings

* docs: add temp plan for binding-service migration

* ACP: migrate thread binding flows to SessionBindingService

* ACP: address review feedback and preserve prompt wording

* ACPX plugin: pin runtime dependency and prefer bundled CLI

* Discord: complete binding-service migration cleanup and restore ACP plan

* Docs: add standalone ACP agents guide

* ACP: route harness intents to thread-bound ACP sessions

* ACP: fix spawn thread routing and queue-owner stall

* ACP: harden startup reconciliation and command bypass handling

* ACP: fix dispatch bypass type narrowing

* ACP: align runtime metadata to agentSessionId

* ACP: normalize session identifier handling and labels

* ACP: mark thread banner session ids provisional until first reply

* ACP: stabilize session identity mapping and startup reconciliation

* ACP: add resolved session-id notices and cwd in thread intros

* Discord: prefix thread meta notices consistently

* Discord: unify ACP/thread meta notices with gear prefix

* Discord: split thread persona naming from meta formatting

* Extensions: bump acpx plugin dependency to 0.1.9

* Agents: gate ACP prompt guidance behind acp.enabled

* Docs: remove temp experiment plan docs

* Docs: scope streaming plan to holy grail refactor

* Docs: refactor ACP agents guide for human-first flow

* Docs/Skill: add ACP feature-flag guidance and direct acpx telephone-game flow

* Docs/Skill: add OpenCode and Pi to ACP harness lists

* Docs/Skill: align ACP harness list with current acpx registry

* Dev/Test: move ACP plain-language smoke script and mark as keep

* Docs/Skill: reorder ACP harness lists with Pi first

* ACP: split control-plane manager into core/types/utils modules

* Docs: refresh ACP thread-bound agents plan

* ACP: extract dispatch lane and split manager domains

* ACP: centralize binding context and remove reverse deps

* Infra: unify system message formatting

* ACP: centralize error boundaries and session id rendering

* ACP: enforce init concurrency cap and strict meta clear

* Tests: fix ACP dispatch binding mock typing

* Tests: fix Discord thread-binding mock drift and ACP request id

* ACP: gate slash bypass and persist cleared overrides

* ACPX: await pre-abort cancel before runTurn return

* Extension: pin acpx runtime dependency to 0.1.11

* Docs: add pinned acpx install strategy for ACP extension

* Extensions/acpx: enforce strict local pinned startup

* Extensions/acpx: tighten acp-router install guidance

* ACPX: retry runtime test temp-dir cleanup

* Extensions/acpx: require proactive ACPX repair for thread spawns

* Extensions/acpx: require restart offer after acpx reinstall

* extensions/acpx: remove workspace protocol devDependency

* extensions/acpx: bump pinned acpx to 0.1.13

* extensions/acpx: sync lockfile after dependency bump

* ACPX: make runtime spawn Windows-safe

* fix: align doctor-config-flow repair tests with default-account migration (#23580) (thanks @osolmaz)
2026-02-26 12:53:44 +01:00
Gustavo Madeira Santana
49ae78865a chore(changelog): move post release entries to unreleased section 2026-02-26 12:53:44 +01:00
Gustavo Madeira Santana
150d4a7cc7 Doctor: ignore slash sessions in transcript integrity check
Merged via deterministic merge flow.

Prepared head SHA: e5cee7a2ec

Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
2026-02-26 12:53:44 +01:00
Ayaan Zaidi
1e1180d142 fix(ssrf): honor global family policy for pinned dispatcher 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
31cb9e44dd fix: changelog for telegram group inline callbacks (#27343) (thanks @GodsBoy) 2026-02-26 12:53:44 +01:00
GodsBoy
133dc7956f fix(telegram): allow inline button callbacks in groups when command was authorized (#27309) 2026-02-26 12:53:44 +01:00
Gustavo Madeira Santana
8bc56095ed Channels: move single-account config into accounts.default (#27334)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 50b5771808
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-26 12:53:44 +01:00
Ayaan Zaidi
640df8608a fix: update changelog for notifications list land (#27344) (thanks @obviyus) 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
317063754b refactor(agents): dedupe node read invoke commands 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
588dbe510f refactor(android): unify notifications.list status flow 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
b71877bb58 feat(agents): add nodes notifications_list action 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
8c17cff3d4 feat(gateway): allow notifications.list for android nodes 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
bef4f37446 feat(android): add notifications.list node command 2026-02-26 12:53:44 +01:00
Sid
9c6dc098ce fix(config): preserve agent-level apiKey/baseUrl during models.json merge (#27293)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 6b4b37b03d
Co-authored-by: Sid-Qin <201593046+Sid-Qin@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-26 12:53:44 +01:00
yinghaosang
28a3fc49b5 docs: fix wrong Providers link in configuration examples 2026-02-26 12:53:44 +01:00
Gustavo Madeira Santana
cb43285e52 Daemon tests: guard undefined runtime status 2026-02-26 12:53:44 +01:00
Gustavo Madeira Santana
a09e37bcbb fix(daemon): keep launchd KeepAlive while preserving restart hardening 2026-02-26 12:53:44 +01:00
Frank Yang
9b03530a21 fix(daemon): stabilize LaunchAgent restart and proxy env passthrough (#27276)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: b08797a995
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-26 12:53:44 +01:00
Gustavo Madeira Santana
2621ce491f Agents: add account-scoped bind and routing commands (#27195)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: ad35a458a5
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-26 12:53:44 +01:00
Ayaan Zaidi
e23915c501 fix: update changelog for android invoke distill (#27257) (thanks @obviyus) 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
755d4f2d39 refactor(android): unify invoke availability gating 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
e7b5fb824d fix(android): require gateway device auth store 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
0c1731136d fix(android): omit websocket Origin for native gateway connect 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
efc7c4f339 refactor(android): unify invoke error parsing 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
512a6e4f9a refactor(android): distill invoke dispatcher command flow 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
968c2a7010 refactor(android): centralize invoke command registry 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
334da9ec46 test(android): cover invoke paramsJSON and error mapping 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
df8a40f7e2 fix(nodes): default camera snap to front high-quality image 2026-02-26 12:53:44 +01:00
Ayaan Zaidi
43762330cd test(android): add GatewaySession invoke roundtrip test 2026-02-26 12:53:43 +01:00
Josh Avant
0e812c0e4f CI: shard Windows test lane for faster CI critical path (#27234)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: f7c41089e0
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
Reviewed-by: @joshavant
2026-02-26 12:53:43 +01:00
Gustavo Madeira Santana
8f8a85567f Onboarding: support plugin-owned interactive channel flows (#27191)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 53872cf8e7
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-26 12:53:43 +01:00
Gustavo Madeira Santana
356e9706f2 chore(ci): fix cross-platform symlink path assertions in agents file tests 2026-02-26 12:53:43 +01:00
Gustavo Madeira Santana
3bb94559d6 pairing: enforce strict account-scoped state 2026-02-26 12:53:43 +01:00
Gustavo Madeira Santana
4c560d0f41 plugin-sdk: export shared timezone formatting helpers (#27196) 2026-02-26 12:53:43 +01:00
Gustavo Madeira Santana
8c4dbc0f71 pairing: isolate account-scoped allowlist and pending requests 2026-02-26 12:53:43 +01:00
Peter Steinberger
ea5cb56e61 fix: harden Docker/GCP onboarding flow (#26253) (thanks @pandego) 2026-02-26 12:53:43 +01:00
pandego
40f343e5d3 Docker/docs: reduce docker build OOM risk on small GCP hosts 2026-02-26 12:53:43 +01:00
Peter Steinberger
1404eb575f docs: fix onboarding markdown list spacing 2026-02-26 12:53:43 +01:00
Matt Hulme
dba4dc632b feat(cron): add --session-key option to cron add/edit CLI commands
Expose the existing CronJob.sessionKey field through the CLI so users
can target cron jobs at specific named sessions without needing an
external shell script + system crontab workaround.

The backend already fully supports sessionKey on cron jobs - this
change wires it to the CLI surface with --session-key on cron add,
and --session-key / --clear-session-key on cron edit.

Closes #27158

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:16:51 -06:00
476 changed files with 5037 additions and 27543 deletions

View File

@@ -6,78 +6,26 @@ Docs: https://docs.openclaw.ai
### Changes
- Highlight: External Secrets Management introduces a full `openclaw secrets` workflow (`audit`, `configure`, `apply`, `reload`) with runtime snapshot activation, strict `secrets apply` target-path validation, safer migration scrubbing, ref-only auth-profile support, and dedicated docs. (#26155) Thanks @joshavant.
- Codex/WebSocket transport: make `openai-codex` WebSocket-first by default (`transport: "auto"` with SSE fallback), keep explicit per-model/runtime transport overrides, and add regression coverage + docs for transport selection.
- Agents/Routing CLI: add `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in `openclaw channels add`. (#27195) thanks @gumadeiras.
- ACP/Thread-bound agents: make ACP agents first-class runtimes for thread sessions with `acp` spawn/send dispatch integration, acpx backend bridging, lifecycle controls, startup reconciliation, runtime cleanup, and coalesced thread replies. (#23580) thanks @osolmaz.
- Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional `configureInteractive` and `configureWhenConfigured` hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.
- Agents/Routing CLI: add `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in `openclaw channels add`. (#27195) thanks @gumadeiras.
- Android/Nodes: add `notifications.list` support on Android nodes and expose `nodes notifications_list` in agent tooling for listing active device notifications. (#27344) thanks @obviyus.
- Android/Nodes: add Android `device` capability plus `device.status` and `device.info` node commands, including runtime handler wiring and protocol/registry coverage for device status/info payloads. (#27664) Thanks @obviyus.
- Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional `configureInteractive` and `configureWhenConfigured` hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.
### Fixes
- Models/MiniMax auth header defaults: set `authHeader: true` for both onboarding-generated MiniMax API providers and implicit built-in MiniMax (`minimax`, `minimax-portal`) provider templates so first requests no longer fail with MiniMax `401 authentication_error` due to missing `Authorization` header. Landed from contributor PRs #27622 by @riccoyuanft and #27631 by @kevinWangSheng. (#27600, #15303)
- Pi image-token usage: stop re-injecting history image blocks each turn, process image references from the current prompt only, and prune already-answered user-image blocks in stored history to prevent runaway token growth. (#27602)
- BlueBubbles/SSRF: auto-allowlist the configured `serverUrl` hostname for attachment fetches so localhost/private-IP BlueBubbles setups are no longer false-blocked by default SSRF checks. Landed from contributor PR #27648 by @lailoo. (#27599) Thanks @taylorhou for reporting.
- Agents/Compaction + onboarding safety: prevent destructive double-compaction by stripping stale assistant usage around compaction boundaries, skipping post-compaction custom metadata writes in the same attempt, and cancelling safeguard compaction when there are no real conversation messages to summarize; harden workspace/bootstrap detection for memory-backed workspaces; and change `openclaw onboard --reset` default scope to `config+creds+sessions` (workspace deletion now requires `--reset-scope full`). (#26458, #27314) Thanks @jaden-clovervnd, @Sid-Qin, and @widingmarcus-cyber for fix direction in #26502, #26529, and #27492.
- Security/Gateway node pairing: pin paired-device `platform`/`deviceFamily` metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (`2026.2.26`). Thanks @76embiid21 for reporting.
- Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only `apply_patch` writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
- Security/Workspace FS boundary aliases: harden canonical boundary resolution for non-existent-leaf symlink aliases while preserving valid in-root aliases, preventing first-write workspace escapes via out-of-root symlink targets. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
- Security/Config includes: harden `$include` file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
- Security/Node exec approvals: require structured `commandArgv` approvals for `host=node`, enforce versioned `systemRunBindingV1` matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add `GIT_EXTERNAL_DIFF` to blocked host env keys. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
- Security/Microsoft Teams media fetch: route Graph message/hosted-content/attachment fetches and auth-scope fallback attachment downloads through shared SSRF-guarded fetch paths, and centralize hostname-suffix allowlist policy helpers in the plugin SDK to remove channel/plugin drift. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
- Microsoft Teams/File uploads: acknowledge `fileConsent/invoke` immediately (`invokeResponse` before upload + file card send) so Teams no longer shows false "Something went wrong" timeout banners while upload completion continues asynchronously; includes updated async regression coverage. Landed from contributor PR #27641 by @scz2011.
- Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed `%`-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
- Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts.
- Queue/Drain/Cron reliability: harden lane draining with guaranteed `draining` flag reset on synchronous pump failures, reject new queue enqueues during gateway restart drain windows (instead of silently killing accepted tasks), add `/stop` queued-backlog cutoff metadata with stale-message skipping (while avoiding cross-session native-stop cutoff bleed), and raise isolated cron `agentTurn` outer safety timeout to avoid false 10-minute timeout races against longer agent session timeouts. (#27407, #27332, #27427)
- Gateway shared-auth scopes: preserve requested operator scopes for shared-token clients when device identity is unavailable, instead of clearing scopes during auth handling. Landed from contributor PR #27498 by @kevinWangSheng. (#27494)
- NO_REPLY suppression: suppress `NO_REPLY` before Slack API send and in sub-agent announce completion flow so sentinel text no longer leaks into user channels. Landed from contributor PRs #27529 (by @Sid-Qin) and #27535 (rewritten minimal landing by maintainers). (#27387, #27531)
- Auto-reply/Streaming: suppress only exact `NO_REPLY` final replies while still filtering streaming partial sentinel fragments (`NO_`, `NO_RE`, `HEARTBEAT_...`) so substantive replies ending with `NO_REPLY` are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim.
- Auto-reply/Inbound metadata: add a readable `timestamp` field to conversation info and ignore invalid/out-of-range timestamp values so prompt assembly never crashes on malformed timestamp inputs. (#17017) thanks @liuy.
- Typing/Main reply pipeline: always mark dispatch idle in `agent-runner` finalization so typing cleanup runs even when dispatcher `onIdle` does not fire, preventing stuck typing indicators after run completion. (#27250) Thanks @Sid-Qin.
- Typing/Run completion race: prevent post-run keepalive ticks from re-triggering typing callbacks by guarding `triggerTyping()` with `runComplete`, with regression coverage for no-restart behavior during run-complete/dispatch-idle boundaries. (#27413) Thanks @widingmarcus-cyber.
- Typing/Dispatch idle: force typing cleanup when `markDispatchIdle` never arrives after run completion, avoiding leaked typing keepalive loops in cron/announce edges. Landed from contributor PR #27541 by @Sid-Qin. (#27493)
- Typing/TTL safety net: add max-duration guardrails to shared typing callbacks so stuck lifecycle edges auto-stop typing indicators even when explicit idle/cleanup signals are missed. (#27428) Thanks @Crpdim.
- Typing/Cross-channel leakage: unify run-scoped typing suppression for cross-channel/internal-webchat routes, preserve current inbound origin as embedded run message channel context, harden shared typing keepalive with consecutive-failure circuit breaker edge-case handling, and enforce dispatcher completion/idle waits in extension dispatcher callsites (Feishu, Matrix, Mattermost, MSTeams) so typing indicators always clean up on success/error paths. Related: #27647, #27493, #27598. Supersedes/replaces draft PRs: #27640, #27593, #27540.
- Onboarding/Gateway: seed default Control UI `allowedOrigins` for non-loopback binds during onboarding (`localhost`/`127.0.0.1` plus custom bind host) so fresh non-loopback setups do not fail startup due to missing origin policy. (#26157) thanks @stakeswky.
- Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during `pnpm install`, reuse existing gateway token during `docker-setup.sh` reruns so `.env` stays aligned with config, auto-bootstrap Control UI allowed origins for non-loopback Docker binds, and add GCP docs guidance for tokenized dashboard links + pairing recovery commands. (#26253) Thanks @pandego.
- Config/Plugins entries: treat unknown `plugins.entries.*` ids as startup warnings (ignored stale keys) instead of hard validation failures that can crash-loop gateway boot. Landed from contributor PR #27506 by @Sid-Qin. (#27455)
- Auth/Auth profiles: normalize `auth-profiles.json` alias fields (`mode -> type`, `apiKey -> key`) before credential validation so entries copied from `openclaw.json` auth examples are no longer silently dropped. (#26950) thanks @byungsker.
- Models/Profile suffix parsing: centralize trailing `@profile` parsing and only treat `@` as a profile separator when it appears after the final `/`, preserving model IDs like `openai/@cf/...` and `openrouter/@preset/...` across `/model` directive parsing and allowlist model resolution, with regression coverage.
- Agents/Models config: preserve agent-level provider `apiKey` and `baseUrl` during merge-mode `models.json` updates when agent values are present. (#27293) thanks @Sid-Qin.
- Cron/Hooks isolated routing: preserve canonical `agent:*` session keys in isolated runs so already-qualified keys are not double-prefixed (for example `agent:main:main` no longer becomes `agent:main:agent:main:main`). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282)
- Pairing/Multi-account isolation: keep non-default account pairing allowlists and pending requests strictly account-scoped, while default account continues to use channel-scoped pairing allowlist storage. Thanks @gumadeiras.
- Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels.<channel>.accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.
- Sessions cleanup/Doctor: add `openclaw sessions cleanup --fix-missing` to prune store entries whose transcript files are missing, including doctor guidance and CLI coverage. Landed from contributor PR #27508 by @Sid-Qin. (#27422)
- Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so `openclaw doctor` no longer reports false-positive transcript-missing warnings for `*:slash:*` keys. (#27375) thanks @gumadeiras.
- Telegram/sendChatAction 401 handling: add bounded exponential backoff + temporary local typing suppression after repeated unauthorized failures to stop unbounded `sendChatAction` retry loops that can trigger Telegram abuse enforcement and bot deletion. (#27415) Thanks @widingmarcus-cyber.
- Telegram native commands: degrade command registration on `BOT_COMMANDS_TOO_MUCH` by retrying with fewer commands instead of crash-looping startup sync. Landed from contributor PR #27512 by @Sid-Qin. (#27456)
- Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels.<channel>.accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.
- Telegram/Inline buttons: allow callback-query button handling in groups (including `/models` follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy.
- Telegram/Streaming preview: when finalizing without an existing preview message, prime pending preview text with final answer before stop-flush so users do not briefly see stale 1-2 word fragments (for example `no` before `no problem`). (#27449) Thanks @emanuelst for the original fix direction in #19673.
- Telegram/Webhook startup: clarify webhook config guidance, allow `channels.telegram.webhookPort: 0` for ephemeral listener binding, and log both the local listener URL and Telegram-advertised webhook URL with the bound port. (#25732) thanks @huntharo.
- Browser/Extension relay CORS: handle `/json*` `OPTIONS` preflight before auth checks, allow Chrome extension origins, and return extension-origin CORS headers on relay HTTP responses so extension token validation no longer fails cross-origin. Landed from contributor PR #23962 by @miloudbelarebia. (#23842)
- Browser/Extension relay auth: allow `?token=` query-param auth on relay `/json*` endpoints (consistent with relay WebSocket auth) so curl/devtools-style `/json/version` and `/json/list` probes work without requiring custom headers. Landed from contributor PR #26015 by @Sid-Qin. (#25928)
- Browser/Chrome extension handshake: bind relay WS message handling before `onopen` and add non-blocking `connect.challenge` response handling for gateway-style handshake frames, avoiding stuck `…` badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553)
- Browser/Extension relay init: dedupe concurrent same-port relay startup with shared in-flight initialization promises so callers await one startup lifecycle and receive consistent success/failure results. Landed from contributor PR #21277 by @HOYALIM. (Related #20688)
- Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay `stop()` before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng.
- Browser/Extension relay reconnect resilience: keep CDP clients alive across brief MV3 extension disconnect windows, wait briefly for extension reconnect before failing in-flight CDP commands, and only tear down relay target/client state after reconnect grace expires. Landed from contributor PR #27617 by @davidemanuelDEV.
- Browser/Route decode hardening: guard malformed percent-encoding in relay target action routes and browser route-param decoding so crafted `%` paths return `400` instead of crashing/unhandled URI decode failures. Landed from contributor PR #11880 by @Yida-Dev.
- Browser/Error visibility: preserve browser-control application error messages (HTTP 4xx/5xx) instead of rewriting them as generic reachability failures. Landed from contributor PR #26380 by @TarasShyn.
- Feishu/Permission error dispatch: merge sender-name permission notices into the main inbound dispatch so one user message produces one agent turn/reply (instead of a duplicate permission-notice turn), with regression coverage. (#27381) thanks @byungsker.
- Feishu/Inbound message metadata: include inbound `message_id` in `BodyForAgent` on a dedicated metadata line so agents can reliably correlate and act on media/message operations that require message IDs, with regression coverage. (#27253) thanks @xss925175263.
- Feishu/Doc tools: route `feishu_doc` and `feishu_app_scopes` through the active agent account context (with explicit `accountId` override support) so multi-account agents no longer default to the first configured app, with regression coverage for context routing and explicit override behavior. (#27338) thanks @AaronL725.
- LINE/Inline directives auth: gate directive parsing (`/model`, `/think`, `/verbose`, `/reasoning`, `/queue`) on resolved authorization (`command.isAuthorizedSender`) so `commands.allowFrom`-authorized LINE senders are not silently stripped when raw `CommandAuthorized` is unset. Landed from contributor PR #27248 by @kevinWangSheng. (#27240)
- Web tools/Proxy: route `web_search` provider HTTP calls (Brave, Perplexity, xAI, Gemini, Kimi), redirect resolution, and `web_fetch` through a shared proxy-aware SSRF guard path so gateway installs behind `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` no longer fail with transport `fetch failed` errors. (#27430) thanks @kevinWangSheng.
- CLI/Gateway status: force local `gateway status` probe host to `127.0.0.1` for `bind=lan` so co-located probes do not trip non-loopback plaintext WebSocket checks. (#26997) thanks @chikko80.
- CLI/Daemon status TLS probe: use `wss://` and forward local TLS certificate fingerprint for TLS-enabled gateway daemon probes so `openclaw daemon status` works with `gateway.bind=lan` + `gateway.tls.enabled=true`. (#24234) thanks @liuy.
- Gateway/Bind visibility: emit a startup warning when binding to non-loopback addresses so operators get explicit exposure guidance in runtime logs. (#25397) thanks @let5sne.
- Podman/Default bind: change `run-openclaw-podman.sh` default gateway bind from `lan` to `loopback` and document explicit LAN opt-in with Control UI origin configuration. (#27491) thanks @robbyczgw-cla.
- Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent `KeepAlive=true` semantics, and harden restart sequencing to `print -> bootout -> wait old pid exit -> bootstrap -> kickstart`. (#27276) thanks @frankekn.
- Gateway/macOS restart-loop hardening: detect OpenClaw-managed supervisor markers during SIGUSR1 restart handoff, clean stale gateway PIDs before `/restart` launchctl/systemctl triggers, and set LaunchAgent `ThrottleInterval=60` to bound launchd retry storms during lock-release races. Landed from contributor PRs #27655 (@taw0002), #27448 (@Sid-Qin), and #27650 (@kevinWangSheng). (#27605, #27590, #26904, #26736)
- Azure OpenAI Responses: force `store=true` for `azure-openai-responses` direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497)
- Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus.
- iOS/Talk mode: stop injecting the voice directive hint into iOS Talk prompts and remove the Voice Directive Hint setting, reducing model bias toward tool-style TTS directives and keeping relay responses text-first by default. (#27543) thanks @ngutman.
- CI/Windows: shard the Windows `checks-windows` test lane into two matrix jobs and honor explicit shard index overrides in `scripts/test-parallel.mjs` to reduce CI critical-path wall time. (#27234) Thanks @joshavant.
- Agents/Models config: preserve agent-level provider `apiKey` and `baseUrl` during merge-mode `models.json` updates when agent values are present. (#27293) thanks @Sid-Qin.
- Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during `pnpm install`, reuse existing gateway token during `docker-setup.sh` reruns so `.env` stays aligned with config, auto-bootstrap Control UI allowed origins for non-loopback Docker binds, and add GCP docs guidance for tokenized dashboard links + pairing recovery commands. (#26253) Thanks @pandego.
- Pairing/Multi-account isolation: keep non-default account pairing allowlists and pending requests strictly account-scoped, while default account continues to use channel-scoped pairing allowlist storage. Thanks @gumadeiras.
- Security/Config includes: harden `$include` file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
- Cron/CLI: add `--session` for session-key routing and rename target selection to `--session-target` for `openclaw cron add/edit`, including `--clear-session` on edit for unsetting the key. (#27167) thanks @Matt-Hulme.
## 2026.2.25
@@ -138,10 +86,8 @@ Docs: https://docs.openclaw.ai
- Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
- Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
- Security/Slack reactions + pins: gate `reaction_*` and `pin_*` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
- Security/Slack member + message subtype events: gate `member_*` plus `message_changed`/`message_deleted`/`thread_broadcast` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress; message subtype system events now fail closed when sender identity is missing, with regression coverage. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
- Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
- Security/Telegram group allowlist: fail closed for group sender authorization by removing DM pairing-store fallback from group allowlist evaluation; group sender access now requires explicit `groupAllowFrom` or per-group/per-topic `allowFrom`. (#25988) Thanks @bmendonca3.
- Security/DM-group allowlist boundaries: keep DM pairing-store approvals DM-only by removing pairing-store inheritance from group sender authorization in LINE and Mattermost message preflight, and by centralizing shared DM/group allowlist composition so group checks never include pairing-store entries. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
- Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
- Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.
- Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.

View File

@@ -50,9 +50,6 @@ Welcome to the lobster tank! 🦞
- **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams
- GitHub: [@onutc](https://github.com/onutc), [@osolmaz](https://github.com/osolmaz) · X: [@onusoz](https://x.com/onusoz)
- **Josh Avant** - Core, CLI, Gateway, Security, Agents
- GitHub: [@joshavant](https://github.com/joshavant) · X: [@joshavant](https://x.com/joshavant)
## How to Contribute
1. **Bugs & small fixes** → Open a PR!

View File

@@ -53,12 +53,10 @@ These are frequently reported but are typically closed with no code change:
- Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass.
- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it.
- Reports that assume per-user multi-tenant authorization on a shared gateway host/config.
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
- Missing HSTS findings on default local/loopback deployments.
- Slack webhook signature findings when HTTP mode already uses signing-secret verification.
- Discord inbound webhook signature findings for paths not used by this repo's Discord integration.
- Claims that Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl` is attacker-controlled without demonstrating one of: auth boundary bypass, a real authenticated Teams/Bot Framework event carrying attacker-chosen URL, or compromise of the Microsoft/Bot trust path.
- Scanner-only claims against stale/nonexistent paths, or claims without a working repro.
### Duplicate Report Handling
@@ -115,10 +113,8 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior).
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses.
- Reports whose only claim is heuristic/parity drift in command-risk detection (for example obfuscation-pattern checks) across exec surfaces, without a demonstrated trust-boundary bypass. These may be accepted as hardening improvements, but not as vulnerabilities.
- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact
- Reports whose only claim is host-side exec when sandbox runtime is disabled/unavailable (documented default behavior in the trusted-operator model), without a boundary bypass.
- Reports whose only claim is that a platform-provided upload destination URL is untrusted (for example Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl`) without proving attacker control in an authenticated production flow.
## Deployment Assumptions

View File

@@ -92,10 +92,6 @@ class NodeRuntime(context: Context) {
locationPreciseEnabled = { locationPreciseEnabled.value },
)
private val deviceHandler: DeviceHandler = DeviceHandler(
appContext = appContext,
)
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
appContext = appContext,
)
@@ -131,7 +127,6 @@ class NodeRuntime(context: Context) {
canvas = canvas,
cameraHandler = cameraHandler,
locationHandler = locationHandler,
deviceHandler = deviceHandler,
notificationsHandler = notificationsHandler,
screenHandler = screenHandler,
smsHandler = smsHandlerImpl,

View File

@@ -1,52 +0,0 @@
package ai.openclaw.android.gateway
internal object DeviceAuthPayload {
fun buildV3(
deviceId: String,
clientId: String,
clientMode: String,
role: String,
scopes: List<String>,
signedAtMs: Long,
token: String?,
nonce: String,
platform: String?,
deviceFamily: String?,
): String {
val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty()
val platformNorm = normalizeMetadataField(platform)
val deviceFamilyNorm = normalizeMetadataField(deviceFamily)
return listOf(
"v3",
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
nonce,
platformNorm,
deviceFamilyNorm,
).joinToString("|")
}
internal fun normalizeMetadataField(value: String?): String {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty()) {
return ""
}
// Keep cross-runtime normalization deterministic (TS/Swift/Kotlin):
// lowercase ASCII A-Z only for auth payload metadata fields.
val out = StringBuilder(trimmed.length)
for (ch in trimmed) {
if (ch in 'A'..'Z') {
out.append((ch.code + 32).toChar())
} else {
out.append(ch)
}
}
return out.toString()
}
}

View File

@@ -372,7 +372,7 @@ class GatewaySession(
val signedAtMs = System.currentTimeMillis()
val payload =
DeviceAuthPayload.buildV3(
buildDeviceAuthPayload(
deviceId = identity.deviceId,
clientId = client.id,
clientMode = client.mode,
@@ -381,8 +381,6 @@ class GatewaySession(
signedAtMs = signedAtMs,
token = if (authToken.isNotEmpty()) authToken else null,
nonce = connectNonce,
platform = client.platform,
deviceFamily = client.deviceFamily,
)
val signature = identityStore.signPayload(payload, identity)
val publicKey = identityStore.publicKeyBase64Url(identity)
@@ -584,6 +582,33 @@ class GatewaySession(
}
}
private fun buildDeviceAuthPayload(
deviceId: String,
clientId: String,
clientMode: String,
role: String,
scopes: List<String>,
signedAtMs: Long,
token: String?,
nonce: String,
): String {
val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty()
val parts =
mutableListOf(
"v2",
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
nonce,
)
return parts.joinToString("|")
}
private fun normalizeCanvasHostUrl(
raw: String?,
endpoint: GatewayEndpoint,

View File

@@ -85,7 +85,6 @@ class ConnectionManager(
buildList {
add(OpenClawCapability.Canvas.rawValue)
add(OpenClawCapability.Screen.rawValue)
add(OpenClawCapability.Device.rawValue)
if (cameraEnabled()) add(OpenClawCapability.Camera.rawValue)
if (smsAvailable()) add(OpenClawCapability.Sms.rawValue)
if (voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission()) {

View File

@@ -1,171 +0,0 @@
package ai.openclaw.android.node
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.os.Build
import android.os.Environment
import android.os.PowerManager
import android.os.StatFs
import android.os.SystemClock
import ai.openclaw.android.BuildConfig
import ai.openclaw.android.gateway.GatewaySession
import java.util.Locale
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
class DeviceHandler(
private val appContext: Context,
) {
fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult {
return GatewaySession.InvokeResult.ok(statusPayloadJson())
}
fun handleDeviceInfo(_paramsJson: String?): GatewaySession.InvokeResult {
return GatewaySession.InvokeResult.ok(infoPayloadJson())
}
private fun statusPayloadJson(): String {
val batteryIntent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val batteryStatus =
batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN)
?: BatteryManager.BATTERY_STATUS_UNKNOWN
val batteryLevel = batteryLevelFraction(batteryIntent)
val powerManager = appContext.getSystemService(PowerManager::class.java)
val storage = StatFs(Environment.getDataDirectory().absolutePath)
val totalBytes = storage.totalBytes
val freeBytes = storage.availableBytes
val usedBytes = (totalBytes - freeBytes).coerceAtLeast(0L)
val connectivity = appContext.getSystemService(ConnectivityManager::class.java)
val activeNetwork = connectivity?.activeNetwork
val caps = activeNetwork?.let { connectivity.getNetworkCapabilities(it) }
val uptimeSeconds = SystemClock.elapsedRealtime() / 1_000.0
return buildJsonObject {
put(
"battery",
buildJsonObject {
batteryLevel?.let { put("level", JsonPrimitive(it)) }
put("state", JsonPrimitive(mapBatteryState(batteryStatus)))
put("lowPowerModeEnabled", JsonPrimitive(powerManager?.isPowerSaveMode == true))
},
)
put(
"thermal",
buildJsonObject {
put("state", JsonPrimitive(mapThermalState(powerManager)))
},
)
put(
"storage",
buildJsonObject {
put("totalBytes", JsonPrimitive(totalBytes))
put("freeBytes", JsonPrimitive(freeBytes))
put("usedBytes", JsonPrimitive(usedBytes))
},
)
put(
"network",
buildJsonObject {
put("status", JsonPrimitive(mapNetworkStatus(caps)))
put(
"isExpensive",
JsonPrimitive(
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)?.not() ?: false,
),
)
put(
"isConstrained",
JsonPrimitive(
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)?.not() ?: false,
),
)
put("interfaces", networkInterfacesJson(caps))
},
)
put("uptimeSeconds", JsonPrimitive(uptimeSeconds))
}.toString()
}
private fun infoPayloadJson(): String {
val model = Build.MODEL?.trim().orEmpty()
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
val modelIdentifier = Build.DEVICE?.trim().orEmpty()
val systemVersion = Build.VERSION.RELEASE?.trim().orEmpty()
val locale = Locale.getDefault().toLanguageTag().trim()
val appVersion = BuildConfig.VERSION_NAME.trim()
val appBuild = BuildConfig.VERSION_CODE.toString()
return buildJsonObject {
put("deviceName", JsonPrimitive(model.ifEmpty { "Android" }))
put("modelIdentifier", JsonPrimitive(modelIdentifier.ifEmpty { listOf(manufacturer, model).filter { it.isNotEmpty() }.joinToString(" ") }))
put("systemName", JsonPrimitive("Android"))
put("systemVersion", JsonPrimitive(systemVersion.ifEmpty { Build.VERSION.SDK_INT.toString() }))
put("appVersion", JsonPrimitive(appVersion.ifEmpty { "dev" }))
put("appBuild", JsonPrimitive(appBuild.ifEmpty { "0" }))
put("locale", JsonPrimitive(locale.ifEmpty { Locale.getDefault().toString() }))
}.toString()
}
private fun batteryLevelFraction(intent: Intent?): Double? {
val rawLevel = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
val rawScale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
if (rawLevel < 0 || rawScale <= 0) return null
return rawLevel.toDouble() / rawScale.toDouble()
}
private fun mapBatteryState(status: Int): String {
return when (status) {
BatteryManager.BATTERY_STATUS_CHARGING -> "charging"
BatteryManager.BATTERY_STATUS_FULL -> "full"
BatteryManager.BATTERY_STATUS_DISCHARGING, BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "unplugged"
else -> "unknown"
}
}
private fun mapThermalState(powerManager: PowerManager?): String {
val thermal = powerManager?.currentThermalStatus ?: return "nominal"
return when (thermal) {
PowerManager.THERMAL_STATUS_NONE, PowerManager.THERMAL_STATUS_LIGHT -> "nominal"
PowerManager.THERMAL_STATUS_MODERATE -> "fair"
PowerManager.THERMAL_STATUS_SEVERE -> "serious"
PowerManager.THERMAL_STATUS_CRITICAL,
PowerManager.THERMAL_STATUS_EMERGENCY,
PowerManager.THERMAL_STATUS_SHUTDOWN -> "critical"
else -> "nominal"
}
}
private fun mapNetworkStatus(caps: NetworkCapabilities?): String {
if (caps == null) return "unsatisfied"
return when {
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) -> "satisfied"
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) -> "requiresConnection"
else -> "unsatisfied"
}
}
private fun networkInterfacesJson(caps: NetworkCapabilities?) =
buildJsonArray {
if (caps == null) return@buildJsonArray
var hasKnownTransport = false
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
hasKnownTransport = true
add(JsonPrimitive("wifi"))
}
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
hasKnownTransport = true
add(JsonPrimitive("cellular"))
}
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
hasKnownTransport = true
add(JsonPrimitive("wired"))
}
if (!hasKnownTransport) add(JsonPrimitive("other"))
}
}

View File

@@ -3,7 +3,6 @@ package ai.openclaw.android.node
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.android.protocol.OpenClawCanvasCommand
import ai.openclaw.android.protocol.OpenClawCameraCommand
import ai.openclaw.android.protocol.OpenClawDeviceCommand
import ai.openclaw.android.protocol.OpenClawLocationCommand
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
import ai.openclaw.android.protocol.OpenClawScreenCommand
@@ -76,12 +75,6 @@ object InvokeCommandRegistry {
name = OpenClawLocationCommand.Get.rawValue,
availability = InvokeCommandAvailability.LocationEnabled,
),
InvokeCommandSpec(
name = OpenClawDeviceCommand.Status.rawValue,
),
InvokeCommandSpec(
name = OpenClawDeviceCommand.Info.rawValue,
),
InvokeCommandSpec(
name = OpenClawNotificationsCommand.List.rawValue,
),

View File

@@ -4,7 +4,6 @@ import ai.openclaw.android.gateway.GatewaySession
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.android.protocol.OpenClawCanvasCommand
import ai.openclaw.android.protocol.OpenClawCameraCommand
import ai.openclaw.android.protocol.OpenClawDeviceCommand
import ai.openclaw.android.protocol.OpenClawLocationCommand
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
import ai.openclaw.android.protocol.OpenClawScreenCommand
@@ -14,7 +13,6 @@ class InvokeDispatcher(
private val canvas: CanvasController,
private val cameraHandler: CameraHandler,
private val locationHandler: LocationHandler,
private val deviceHandler: DeviceHandler,
private val notificationsHandler: NotificationsHandler,
private val screenHandler: ScreenHandler,
private val smsHandler: SmsHandler,
@@ -118,10 +116,6 @@ class InvokeDispatcher(
// Location command
OpenClawLocationCommand.Get.rawValue -> locationHandler.handleLocationGet(paramsJson)
// Device commands
OpenClawDeviceCommand.Status.rawValue -> deviceHandler.handleDeviceStatus(paramsJson)
OpenClawDeviceCommand.Info.rawValue -> deviceHandler.handleDeviceInfo(paramsJson)
// Notifications command
OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson)

View File

@@ -7,7 +7,6 @@ enum class OpenClawCapability(val rawValue: String) {
Sms("sms"),
VoiceWake("voiceWake"),
Location("location"),
Device("device"),
}
enum class OpenClawCanvasCommand(val rawValue: String) {
@@ -71,16 +70,6 @@ enum class OpenClawLocationCommand(val rawValue: String) {
}
}
enum class OpenClawDeviceCommand(val rawValue: String) {
Status("device.status"),
Info("device.info"),
;
companion object {
const val NamespacePrefix: String = "device."
}
}
enum class OpenClawNotificationsCommand(val rawValue: String) {
List("notifications.list"),
;

View File

@@ -1,35 +0,0 @@
package ai.openclaw.android.gateway
import org.junit.Assert.assertEquals
import org.junit.Test
class DeviceAuthPayloadTest {
@Test
fun buildV3_matchesCanonicalVector() {
val payload =
DeviceAuthPayload.buildV3(
deviceId = "dev-1",
clientId = "openclaw-macos",
clientMode = "ui",
role = "operator",
scopes = listOf("operator.admin", "operator.read"),
signedAtMs = 1_700_000_000_000,
token = "tok-123",
nonce = "nonce-abc",
platform = " IOS ",
deviceFamily = " iPhone ",
)
assertEquals(
"v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone",
payload,
)
}
@Test
fun normalizeMetadataField_asciiOnlyLowercase() {
assertEquals("İos", DeviceAuthPayload.normalizeMetadataField(" İOS "))
assertEquals("mac", DeviceAuthPayload.normalizeMetadataField(" MAC "))
assertEquals("", DeviceAuthPayload.normalizeMetadataField(null))
}
}

View File

@@ -1,82 +0,0 @@
package ai.openclaw.android.node
import android.content.Context
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.double
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class DeviceHandlerTest {
@Test
fun handleDeviceInfo_returnsStablePayload() {
val handler = DeviceHandler(appContext())
val result = handler.handleDeviceInfo(null)
assertTrue(result.ok)
val payload = parsePayload(result.payloadJson)
assertEquals("Android", payload.getValue("systemName").jsonPrimitive.content)
assertTrue(payload.getValue("deviceName").jsonPrimitive.content.isNotBlank())
assertTrue(payload.getValue("modelIdentifier").jsonPrimitive.content.isNotBlank())
assertTrue(payload.getValue("systemVersion").jsonPrimitive.content.isNotBlank())
assertTrue(payload.getValue("appVersion").jsonPrimitive.content.isNotBlank())
assertTrue(payload.getValue("appBuild").jsonPrimitive.content.isNotBlank())
assertTrue(payload.getValue("locale").jsonPrimitive.content.isNotBlank())
}
@Test
fun handleDeviceStatus_returnsExpectedShape() {
val handler = DeviceHandler(appContext())
val result = handler.handleDeviceStatus(null)
assertTrue(result.ok)
val payload = parsePayload(result.payloadJson)
val battery = payload.getValue("battery").jsonObject
val storage = payload.getValue("storage").jsonObject
val thermal = payload.getValue("thermal").jsonObject
val network = payload.getValue("network").jsonObject
val state = battery.getValue("state").jsonPrimitive.content
assertTrue(state in setOf("unknown", "unplugged", "charging", "full"))
battery["level"]?.jsonPrimitive?.double?.let { level ->
assertTrue(level in 0.0..1.0)
}
battery.getValue("lowPowerModeEnabled").jsonPrimitive.boolean
val totalBytes = storage.getValue("totalBytes").jsonPrimitive.content.toLong()
val freeBytes = storage.getValue("freeBytes").jsonPrimitive.content.toLong()
val usedBytes = storage.getValue("usedBytes").jsonPrimitive.content.toLong()
assertTrue(totalBytes >= 0L)
assertTrue(freeBytes >= 0L)
assertTrue(usedBytes >= 0L)
assertEquals((totalBytes - freeBytes).coerceAtLeast(0L), usedBytes)
val thermalState = thermal.getValue("state").jsonPrimitive.content
assertTrue(thermalState in setOf("nominal", "fair", "serious", "critical"))
val networkStatus = network.getValue("status").jsonPrimitive.content
assertTrue(networkStatus in setOf("satisfied", "unsatisfied", "requiresConnection"))
val interfaces = network.getValue("interfaces").jsonArray.map { it.jsonPrimitive.content }
assertTrue(interfaces.all { it in setOf("wifi", "cellular", "wired", "other") })
assertTrue(payload.getValue("uptimeSeconds").jsonPrimitive.double >= 0.0)
}
private fun appContext(): Context = RuntimeEnvironment.getApplication()
private fun parsePayload(payloadJson: String?): JsonObject {
val jsonString = payloadJson ?: error("expected payload")
return Json.parseToJsonElement(jsonString).jsonObject
}
}

View File

@@ -1,7 +1,6 @@
package ai.openclaw.android.node
import ai.openclaw.android.protocol.OpenClawCameraCommand
import ai.openclaw.android.protocol.OpenClawDeviceCommand
import ai.openclaw.android.protocol.OpenClawLocationCommand
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
import ai.openclaw.android.protocol.OpenClawSmsCommand
@@ -23,8 +22,6 @@ class InvokeCommandRegistryTest {
assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue))
assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue))
assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue))
assertFalse(commands.contains("debug.logs"))
@@ -45,8 +42,6 @@ class InvokeCommandRegistryTest {
assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue))
assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue))
assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue))
assertTrue(commands.contains("debug.logs"))

View File

@@ -26,9 +26,6 @@ class OpenClawProtocolConstantsTest {
assertEquals("camera", OpenClawCapability.Camera.rawValue)
assertEquals("screen", OpenClawCapability.Screen.rawValue)
assertEquals("voiceWake", OpenClawCapability.VoiceWake.rawValue)
assertEquals("location", OpenClawCapability.Location.rawValue)
assertEquals("sms", OpenClawCapability.Sms.rawValue)
assertEquals("device", OpenClawCapability.Device.rawValue)
}
@Test
@@ -40,10 +37,4 @@ class OpenClawProtocolConstantsTest {
fun notificationsCommandsUseStableStrings() {
assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue)
}
@Test
fun deviceCommandsUseStableStrings() {
assertEquals("device.status", OpenClawDeviceCommand.Status.rawValue)
assertEquals("device.info", OpenClawDeviceCommand.Info.rawValue)
}
}

View File

@@ -22,6 +22,7 @@ struct SettingsTab: View {
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
@AppStorage("talk.voiceDirectiveHint.enabled") private var talkVoiceDirectiveHintEnabled: Bool = true
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = OpenClawLocationMode.off.rawValue
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@@ -325,6 +326,10 @@ struct SettingsTab: View {
.font(.footnote)
.foregroundStyle(.secondary)
}
self.featureToggle(
"Voice Directive Hint",
isOn: self.$talkVoiceDirectiveHintEnabled,
help: "Adds voice-switching instructions to Talk prompts. Disable to reduce prompt size.")
self.featureToggle(
"Show Talk Button",
isOn: self.$talkButtonEnabled,

View File

@@ -850,10 +850,11 @@ final class TalkModeManager: NSObject {
private func buildPrompt(transcript: String) -> String {
let interrupted = self.lastInterruptedAtSeconds
self.lastInterruptedAtSeconds = nil
let includeVoiceDirectiveHint = (UserDefaults.standard.object(forKey: "talk.voiceDirectiveHint.enabled") as? Bool) ?? true
return TalkPromptBuilder.build(
transcript: transcript,
interruptedAtSeconds: interrupted,
includeVoiceDirectiveHint: false)
includeVoiceDirectiveHint: includeVoiceDirectiveHint)
}
private enum ChatCompletionState: CustomStringConvertible {

View File

@@ -1,11 +1,36 @@
import Foundation
enum HostEnvSanitizer {
/// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs.
/// Keep in sync with src/infra/host-env-security-policy.json.
/// Parity is validated by src/infra/host-env-security.policy-parity.test.ts.
private static let blockedKeys = HostEnvSecurityPolicy.blockedKeys
private static let blockedPrefixes = HostEnvSecurityPolicy.blockedPrefixes
private static let blockedOverrideKeys = HostEnvSecurityPolicy.blockedOverrideKeys
private static let blockedKeys: Set<String> = [
"NODE_OPTIONS",
"NODE_PATH",
"PYTHONHOME",
"PYTHONPATH",
"PERL5LIB",
"PERL5OPT",
"RUBYLIB",
"RUBYOPT",
"BASH_ENV",
"ENV",
"SHELL",
"SHELLOPTS",
"PS4",
"GCONV_PATH",
"IFS",
"SSLKEYLOGFILE",
]
private static let blockedPrefixes: [String] = [
"DYLD_",
"LD_",
"BASH_FUNC_",
]
private static let blockedOverrideKeys: Set<String> = [
"HOME",
"ZDOTDIR",
]
private static let shellWrapperAllowedOverrideKeys: Set<String> = [
"TERM",
"LANG",

View File

@@ -1,38 +0,0 @@
// Generated file. Do not edit directly.
// Source: src/infra/host-env-security-policy.json
// Regenerate: node scripts/generate-host-env-security-policy-swift.mjs --write
import Foundation
enum HostEnvSecurityPolicy {
static let blockedKeys: Set<String> = [
"NODE_OPTIONS",
"NODE_PATH",
"PYTHONHOME",
"PYTHONPATH",
"PERL5LIB",
"PERL5OPT",
"RUBYLIB",
"RUBYOPT",
"BASH_ENV",
"ENV",
"GIT_EXTERNAL_DIFF",
"SHELL",
"SHELLOPTS",
"PS4",
"GCONV_PATH",
"IFS",
"SSLKEYLOGFILE"
]
static let blockedOverrideKeys: Set<String> = [
"HOME",
"ZDOTDIR"
]
static let blockedPrefixes: [String] = [
"DYLD_",
"LD_",
"BASH_FUNC_"
]
}

View File

@@ -280,17 +280,19 @@ actor GatewayWizardClient {
let connectNonce = try await self.waitForConnectChallenge()
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let payload = GatewayDeviceAuthPayload.buildV3(
deviceId: identity.deviceId,
clientId: clientId,
clientMode: clientMode,
role: role,
scopes: scopes,
signedAtMs: signedAtMs,
token: self.token,
nonce: connectNonce,
platform: platform,
deviceFamily: "Mac")
let scopesValue = scopes.joined(separator: ",")
let payloadParts = [
"v2",
identity.deviceId,
clientId,
clientMode,
role,
scopesValue,
String(signedAtMs),
self.token ?? "",
connectNonce,
]
let payload = payloadParts.joined(separator: "|")
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
{

View File

@@ -2810,7 +2810,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String
public let commandargv: [String]?
public let env: [String: AnyCodable]?
public let cwd: AnyCodable?
public let nodeid: AnyCodable?
public let host: AnyCodable?
@@ -2819,10 +2818,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public let agentid: AnyCodable?
public let resolvedpath: AnyCodable?
public let sessionkey: AnyCodable?
public let turnsourcechannel: AnyCodable?
public let turnsourceto: AnyCodable?
public let turnsourceaccountid: AnyCodable?
public let turnsourcethreadid: AnyCodable?
public let timeoutms: Int?
public let twophase: Bool?
@@ -2830,7 +2825,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
id: String?,
command: String,
commandargv: [String]?,
env: [String: AnyCodable]?,
cwd: AnyCodable?,
nodeid: AnyCodable?,
host: AnyCodable?,
@@ -2839,17 +2833,12 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
agentid: AnyCodable?,
resolvedpath: AnyCodable?,
sessionkey: AnyCodable?,
turnsourcechannel: AnyCodable?,
turnsourceto: AnyCodable?,
turnsourceaccountid: AnyCodable?,
turnsourcethreadid: AnyCodable?,
timeoutms: Int?,
twophase: Bool?)
{
self.id = id
self.command = command
self.commandargv = commandargv
self.env = env
self.cwd = cwd
self.nodeid = nodeid
self.host = host
@@ -2858,10 +2847,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
self.agentid = agentid
self.resolvedpath = resolvedpath
self.sessionkey = sessionkey
self.turnsourcechannel = turnsourcechannel
self.turnsourceto = turnsourceto
self.turnsourceaccountid = turnsourceaccountid
self.turnsourcethreadid = turnsourcethreadid
self.timeoutms = timeoutms
self.twophase = twophase
}
@@ -2870,7 +2855,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
case id
case command
case commandargv = "commandArgv"
case env
case cwd
case nodeid = "nodeId"
case host
@@ -2879,10 +2863,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
case agentid = "agentId"
case resolvedpath = "resolvedPath"
case sessionkey = "sessionKey"
case turnsourcechannel = "turnSourceChannel"
case turnsourceto = "turnSourceTo"
case turnsourceaccountid = "turnSourceAccountId"
case turnsourcethreadid = "turnSourceThreadId"
case timeoutms = "timeoutMs"
case twophase = "twoPhase"
}
@@ -2996,7 +2976,6 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
public let publickey: String
public let displayname: String?
public let platform: String?
public let devicefamily: String?
public let clientid: String?
public let clientmode: String?
public let role: String?
@@ -3013,7 +2992,6 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
publickey: String,
displayname: String?,
platform: String?,
devicefamily: String?,
clientid: String?,
clientmode: String?,
role: String?,
@@ -3029,7 +3007,6 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
self.publickey = publickey
self.displayname = displayname
self.platform = platform
self.devicefamily = devicefamily
self.clientid = clientid
self.clientmode = clientmode
self.role = role
@@ -3047,7 +3024,6 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
case publickey = "publicKey"
case displayname = "displayName"
case platform
case devicefamily = "deviceFamily"
case clientid = "clientId"
case clientmode = "clientMode"
case role

View File

@@ -1,55 +0,0 @@
import Foundation
public enum GatewayDeviceAuthPayload {
public static func buildV3(
deviceId: String,
clientId: String,
clientMode: String,
role: String,
scopes: [String],
signedAtMs: Int,
token: String?,
nonce: String,
platform: String?,
deviceFamily: String?) -> String
{
let scopeString = scopes.joined(separator: ",")
let authToken = token ?? ""
let normalizedPlatform = normalizeMetadataField(platform)
let normalizedDeviceFamily = normalizeMetadataField(deviceFamily)
return [
"v3",
deviceId,
clientId,
clientMode,
role,
scopeString,
String(signedAtMs),
authToken,
nonce,
normalizedPlatform,
normalizedDeviceFamily,
].joined(separator: "|")
}
static func normalizeMetadataField(_ value: String?) -> String {
guard let value else { return "" }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return ""
}
// Keep cross-runtime normalization deterministic (TS/Swift/Kotlin):
// lowercase ASCII A-Z only for auth payload metadata fields.
var output = String()
output.reserveCapacity(trimmed.count)
for scalar in trimmed.unicodeScalars {
let codePoint = scalar.value
if codePoint >= 65, codePoint <= 90, let lowered = UnicodeScalar(codePoint + 32) {
output.unicodeScalars.append(lowered)
} else {
output.unicodeScalars.append(scalar)
}
}
return output
}
}

View File

@@ -398,18 +398,20 @@ public actor GatewayChannelActor {
}
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let connectNonce = try await self.waitForConnectChallenge()
let scopesValue = scopes.joined(separator: ",")
let payloadParts = [
"v2",
identity?.deviceId ?? "",
clientId,
clientMode,
role,
scopesValue,
String(signedAtMs),
authToken ?? "",
connectNonce,
]
let payload = payloadParts.joined(separator: "|")
if includeDeviceIdentity, let identity {
let payload = GatewayDeviceAuthPayload.buildV3(
deviceId: identity.deviceId,
clientId: clientId,
clientMode: clientMode,
role: role,
scopes: scopes,
signedAtMs: signedAtMs,
token: authToken,
nonce: connectNonce,
platform: platform,
deviceFamily: InstanceIdentity.deviceFamily)
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
let device: [String: ProtoAnyCodable] = [

View File

@@ -2810,7 +2810,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String
public let commandargv: [String]?
public let env: [String: AnyCodable]?
public let cwd: AnyCodable?
public let nodeid: AnyCodable?
public let host: AnyCodable?
@@ -2819,10 +2818,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public let agentid: AnyCodable?
public let resolvedpath: AnyCodable?
public let sessionkey: AnyCodable?
public let turnsourcechannel: AnyCodable?
public let turnsourceto: AnyCodable?
public let turnsourceaccountid: AnyCodable?
public let turnsourcethreadid: AnyCodable?
public let timeoutms: Int?
public let twophase: Bool?
@@ -2830,7 +2825,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
id: String?,
command: String,
commandargv: [String]?,
env: [String: AnyCodable]?,
cwd: AnyCodable?,
nodeid: AnyCodable?,
host: AnyCodable?,
@@ -2839,17 +2833,12 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
agentid: AnyCodable?,
resolvedpath: AnyCodable?,
sessionkey: AnyCodable?,
turnsourcechannel: AnyCodable?,
turnsourceto: AnyCodable?,
turnsourceaccountid: AnyCodable?,
turnsourcethreadid: AnyCodable?,
timeoutms: Int?,
twophase: Bool?)
{
self.id = id
self.command = command
self.commandargv = commandargv
self.env = env
self.cwd = cwd
self.nodeid = nodeid
self.host = host
@@ -2858,10 +2847,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
self.agentid = agentid
self.resolvedpath = resolvedpath
self.sessionkey = sessionkey
self.turnsourcechannel = turnsourcechannel
self.turnsourceto = turnsourceto
self.turnsourceaccountid = turnsourceaccountid
self.turnsourcethreadid = turnsourcethreadid
self.timeoutms = timeoutms
self.twophase = twophase
}
@@ -2870,7 +2855,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
case id
case command
case commandargv = "commandArgv"
case env
case cwd
case nodeid = "nodeId"
case host
@@ -2879,10 +2863,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
case agentid = "agentId"
case resolvedpath = "resolvedPath"
case sessionkey = "sessionKey"
case turnsourcechannel = "turnSourceChannel"
case turnsourceto = "turnSourceTo"
case turnsourceaccountid = "turnSourceAccountId"
case turnsourcethreadid = "turnSourceThreadId"
case timeoutms = "timeoutMs"
case twophase = "twoPhase"
}
@@ -2996,7 +2976,6 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
public let publickey: String
public let displayname: String?
public let platform: String?
public let devicefamily: String?
public let clientid: String?
public let clientmode: String?
public let role: String?
@@ -3013,7 +2992,6 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
publickey: String,
displayname: String?,
platform: String?,
devicefamily: String?,
clientid: String?,
clientmode: String?,
role: String?,
@@ -3029,7 +3007,6 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
self.publickey = publickey
self.displayname = displayname
self.platform = platform
self.devicefamily = devicefamily
self.clientid = clientid
self.clientmode = clientmode
self.role = role
@@ -3047,7 +3024,6 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
case publickey = "publicKey"
case displayname = "displayName"
case platform
case devicefamily = "deviceFamily"
case clientid = "clientId"
case clientmode = "clientMode"
case role

View File

@@ -1,30 +0,0 @@
import Testing
@testable import OpenClawKit
@Suite("DeviceAuthPayload")
struct DeviceAuthPayloadTests {
@Test("builds canonical v3 payload vector")
func buildsCanonicalV3PayloadVector() {
let payload = GatewayDeviceAuthPayload.buildV3(
deviceId: "dev-1",
clientId: "openclaw-macos",
clientMode: "ui",
role: "operator",
scopes: ["operator.admin", "operator.read"],
signedAtMs: 1_700_000_000_000,
token: "tok-123",
nonce: "nonce-abc",
platform: " IOS ",
deviceFamily: " iPhone ")
#expect(
payload
== "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone")
}
@Test("normalizes metadata with ASCII-only lowercase")
func normalizesMetadataWithAsciiLowercase() {
#expect(GatewayDeviceAuthPayload.normalizeMetadataField(" İOS ") == "İos")
#expect(GatewayDeviceAuthPayload.normalizeMetadataField(" MAC ") == "mac")
#expect(GatewayDeviceAuthPayload.normalizeMetadataField(nil) == "")
}
}

View File

@@ -13,9 +13,6 @@ const BADGE = {
let relayWs = null
/** @type {Promise<void>|null} */
let relayConnectPromise = null
let relayGatewayToken = ''
/** @type {string|null} */
let relayConnectRequestId = null
let nextSession = 1
@@ -146,13 +143,6 @@ async function ensureRelayConnection() {
const ws = new WebSocket(wsUrl)
relayWs = ws
relayGatewayToken = gatewayToken
// Bind message handler before open so an immediate first frame (for example
// gateway connect.challenge) cannot be missed.
ws.onmessage = (event) => {
if (ws !== relayWs) return
void whenReady(() => onRelayMessage(String(event.data || '')))
}
await new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000)
@@ -172,6 +162,10 @@ async function ensureRelayConnection() {
// Bind permanent handlers. Guard against stale socket: if this WS was
// replaced before its close fires, the handler is a no-op.
ws.onmessage = (event) => {
if (ws !== relayWs) return
void whenReady(() => onRelayMessage(String(event.data || '')))
}
ws.onclose = () => {
if (ws !== relayWs) return
onRelayClosed('closed')
@@ -194,8 +188,6 @@ async function ensureRelayConnection() {
// Debugger sessions are kept alive so they survive transient WS drops.
function onRelayClosed(reason) {
relayWs = null
relayGatewayToken = ''
relayConnectRequestId = null
for (const [id, p] of pending.entries()) {
pending.delete(id)
@@ -316,33 +308,6 @@ function sendToRelay(payload) {
ws.send(JSON.stringify(payload))
}
function ensureGatewayHandshakeStarted(payload) {
if (relayConnectRequestId) return
const nonce = typeof payload?.nonce === 'string' ? payload.nonce.trim() : ''
relayConnectRequestId = `ext-connect-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
sendToRelay({
type: 'req',
id: relayConnectRequestId,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'chrome-relay-extension',
version: '1.0.0',
platform: 'chrome-extension',
mode: 'webchat',
},
role: 'operator',
scopes: ['operator.read', 'operator.write'],
caps: [],
commands: [],
nonce: nonce || undefined,
auth: relayGatewayToken ? { token: relayGatewayToken } : undefined,
},
})
}
async function maybeOpenHelpOnce() {
try {
const stored = await chrome.storage.local.get(['helpOnErrorShown'])
@@ -384,33 +349,6 @@ async function onRelayMessage(text) {
return
}
if (msg && msg.type === 'event' && msg.event === 'connect.challenge') {
try {
ensureGatewayHandshakeStarted(msg.payload)
} catch (err) {
console.warn('gateway connect handshake start failed', err instanceof Error ? err.message : String(err))
relayConnectRequestId = null
const ws = relayWs
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close(1008, 'gateway connect failed')
}
}
return
}
if (msg && msg.type === 'res' && relayConnectRequestId && msg.id === relayConnectRequestId) {
relayConnectRequestId = null
if (!msg.ok) {
const detail = msg?.error?.message || msg?.error || 'gateway connect failed'
console.warn('gateway connect handshake rejected', String(detail))
const ws = relayWs
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close(1008, 'gateway connect failed')
}
}
return
}
if (msg && msg.method === 'ping') {
try {
sendToRelay({ method: 'pong' })

View File

@@ -38,7 +38,7 @@ Create a one-shot reminder, verify it exists, and run it immediately:
openclaw cron add \
--name "Reminder" \
--at "2026-02-01T16:00:00Z" \
--session main \
--session-target main \
--system-event "Reminder: check the cron docs draft" \
--wake now \
--delete-after-run
@@ -55,7 +55,7 @@ openclaw cron add \
--name "Morning brief" \
--cron "0 7 * * *" \
--tz "America/Los_Angeles" \
--session isolated \
--session-target isolated \
--message "Summarize overnight updates." \
--announce \
--channel slack \
@@ -479,7 +479,7 @@ One-shot reminder (UTC ISO, auto-delete after success):
openclaw cron add \
--name "Send reminder" \
--at "2026-01-12T18:00:00Z" \
--session main \
--session-target main \
--system-event "Reminder: submit expense report." \
--wake now \
--delete-after-run
@@ -491,7 +491,7 @@ One-shot reminder (main session, wake immediately):
openclaw cron add \
--name "Calendar check" \
--at "20m" \
--session main \
--session-target main \
--system-event "Next heartbeat: check calendar." \
--wake now
```
@@ -503,7 +503,7 @@ openclaw cron add \
--name "Morning status" \
--cron "0 7 * * *" \
--tz "America/Los_Angeles" \
--session isolated \
--session-target isolated \
--message "Summarize inbox + calendar for today." \
--announce \
--channel whatsapp \
@@ -518,7 +518,7 @@ openclaw cron add \
--cron "0 * * * * *" \
--tz "UTC" \
--stagger 30s \
--session isolated \
--session-target isolated \
--message "Run minute watcher checks." \
--announce
```
@@ -530,7 +530,7 @@ openclaw cron add \
--name "Nightly summary (topic)" \
--cron "0 22 * * *" \
--tz "America/Los_Angeles" \
--session isolated \
--session-target isolated \
--message "Summarize today; send to the nightly topic." \
--announce \
--channel telegram \
@@ -544,7 +544,7 @@ openclaw cron add \
--name "Deep analysis" \
--cron "0 6 * * 1" \
--tz "America/Los_Angeles" \
--session isolated \
--session-target isolated \
--message "Weekly deep analysis of project progress." \
--model "opus" \
--thinking high \
@@ -557,7 +557,7 @@ Agent selection (multi-agent setups):
```bash
# Pin a job to agent "ops" (falls back to default if that agent is missing)
openclaw cron add --name "Ops sweep" --cron "0 6 * * *" --session isolated --message "Check ops queue" --agent ops
openclaw cron add --name "Ops sweep" --cron "0 6 * * *" --session-target isolated --message "Check ops queue" --agent ops
# Switch or clear the agent on an existing job
openclaw cron edit <jobId> --agent ops

View File

@@ -106,7 +106,7 @@ openclaw cron add \
--name "Morning briefing" \
--cron "0 7 * * *" \
--tz "America/New_York" \
--session isolated \
--session-target isolated \
--message "Generate today's briefing: weather, calendar, top emails, news summary." \
--model opus \
--announce \
@@ -122,7 +122,7 @@ This runs at exactly 7:00 AM New York time, uses Opus for quality, and announces
openclaw cron add \
--name "Meeting reminder" \
--at "20m" \
--session main \
--session-target main \
--system-event "Reminder: standup meeting starts in 10 minutes." \
--wake now \
--delete-after-run
@@ -178,13 +178,13 @@ The most efficient setup uses **both**:
```bash
# Daily morning briefing at 7am
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --announce
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session-target isolated --message "..." --announce
# Weekly project review on Mondays at 9am
openclaw cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus
openclaw cron add --name "Weekly review" --cron "0 9 * * 1" --session-target isolated --message "..." --model opus
# One-shot reminder
openclaw cron add --name "Call back" --at "2h" --session main --system-event "Call back the client" --wake now
openclaw cron add --name "Call back" --at "2h" --session-target main --system-event "Call back the client" --wake now
```
## Lobster: Deterministic workflows with approvals
@@ -229,7 +229,7 @@ Both heartbeat and cron can interact with the main session, but differently:
### When to use main session cron
Use `--session main` with `--system-event` when you want:
Use `--session-target main` with `--system-event` when you want:
- The reminder/event to appear in main session context
- The agent to handle it during the next heartbeat with full context
@@ -239,14 +239,14 @@ Use `--session main` with `--system-event` when you want:
openclaw cron add \
--name "Check project" \
--every "4h" \
--session main \
--session-target main \
--system-event "Time for a project health check" \
--wake now
```
### When to use isolated cron
Use `--session isolated` when you want:
Use `--session-target isolated` when you want:
- A clean slate without prior context
- Different model or thinking settings
@@ -257,7 +257,7 @@ Use `--session isolated` when you want:
openclaw cron add \
--name "Deep analysis" \
--cron "0 6 * * 0" \
--session isolated \
--session-target isolated \
--message "Weekly codebase analysis..." \
--model opus \
--thinking high \

View File

@@ -671,10 +671,9 @@ Default slash command settings:
- `session.threadBindings.*` sets global defaults.
- `channels.discord.threadBindings.*` overrides Discord behavior.
- `spawnSubagentSessions` must be true to auto-create/bind threads for `sessions_spawn({ thread: true })`.
- `spawnAcpSessions` must be true to auto-create/bind threads for ACP (`/acp spawn ... --thread ...` or `sessions_spawn({ runtime: "acp", thread: true })`).
- If thread bindings are disabled for an account, `/focus` and related thread binding operations are unavailable.
See [Sub-agents](/tools/subagents), [ACP Agents](/tools/acp-agents), and [Configuration Reference](/gateway/configuration-reference).
See [Sub-agents](/tools/subagents) and [Configuration Reference](/gateway/configuration-reference).
</Accordion>

View File

@@ -166,7 +166,6 @@ Use these identifiers for delivery and allowlists:
googlechat: {
enabled: true,
serviceAccountFile: "/path/to/service-account.json",
// or serviceAccountRef: { source: "file", provider: "filemain", id: "/channels/googlechat/serviceAccount" }
audienceType: "app-url",
audience: "https://gateway.example.com/googlechat",
webhookPath: "/googlechat",
@@ -195,15 +194,12 @@ Use these identifiers for delivery and allowlists:
Notes:
- Service account credentials can also be passed inline with `serviceAccount` (JSON string).
- `serviceAccountRef` is also supported (env/file SecretRef), including per-account refs under `channels.googlechat.accounts.<id>.serviceAccountRef`.
- Default webhook path is `/googlechat` if `webhookPath` isnt set.
- `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode).
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).
Secrets reference details: [Secrets Management](/gateway/secrets).
## Troubleshooting
### 405 Method Not Allowed

View File

@@ -184,7 +184,6 @@ Notes:
- `groupPolicy` is separate from mention-gating (which requires @mentions).
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams/Zalo: use `groupAllowFrom` (fallback: explicit `allowFrom`).
- DM pairing approvals (`*-allowFrom` store entries) apply to DM access only; group sender authorization stays explicit to group allowlists.
- Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
- Slack: allowlist uses `channels.slack.channels`.
- Matrix: allowlist uses `channels.matrix.groups` (room IDs, aliases, or names). Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported.

View File

@@ -52,7 +52,6 @@ This page describes the current CLI behavior. If commands change, update this do
- [`plugins`](/cli/plugins) (plugin commands)
- [`channels`](/cli/channels)
- [`security`](/cli/security)
- [`secrets`](/cli/secrets)
- [`skills`](/cli/skills)
- [`daemon`](/cli/daemon) (legacy alias for gateway service commands)
- [`clawbot`](/cli/clawbot) (legacy alias namespace)
@@ -105,9 +104,6 @@ openclaw [--dev] [--profile <name>] <command>
dashboard
security
audit
secrets
reload
migrate
reset
uninstall
update
@@ -267,13 +263,6 @@ Note: plugins can add additional top-level commands (for example `openclaw voice
- `openclaw security audit --deep` — best-effort live Gateway probe.
- `openclaw security audit --fix` — tighten safe defaults and chmod state/config.
## Secrets
- `openclaw secrets reload` — re-resolve refs and atomically swap the runtime snapshot.
- `openclaw secrets audit` — scan for plaintext residues, unresolved refs, and precedence drift.
- `openclaw secrets configure` — interactive helper for provider setup + SecretRef mapping + preflight/apply.
- `openclaw secrets apply --from <plan.json>` — apply a previously generated plan (`--dry-run` supported).
## Plugins
Manage extensions and their config:
@@ -328,8 +317,7 @@ Interactive wizard to set up gateway, workspace, and skills.
Options:
- `--workspace <dir>`
- `--reset` (reset config + credentials + sessions before wizard)
- `--reset-scope <config|config+creds+sessions|full>` (default `config+creds+sessions`; use `full` to also remove workspace)
- `--reset` (reset config + credentials + sessions + workspace before wizard)
- `--non-interactive`
- `--mode <local|remote>`
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
@@ -338,7 +326,6 @@ Options:
- `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
- `--token-expires-in <duration>` (non-interactive; e.g. `365d`, `12h`)
- `--secret-input-mode <plaintext|ref>` (default `plaintext`; use `ref` to store provider default env refs instead of plaintext keys)
- `--anthropic-api-key <key>`
- `--openai-api-key <key>`
- `--mistral-api-key <key>`

View File

@@ -34,39 +34,11 @@ openclaw onboard --non-interactive \
--custom-base-url "https://llm.example.com/v1" \
--custom-model-id "foo-large" \
--custom-api-key "$CUSTOM_API_KEY" \
--secret-input-mode plaintext \
--custom-compatibility openai
```
`--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`.
Store provider keys as refs instead of plaintext:
```bash
openclaw onboard --non-interactive \
--auth-choice openai-api-key \
--secret-input-mode ref \
--accept-risk
```
With `--secret-input-mode ref`, onboarding writes env-backed refs instead of plaintext key values.
For auth-profile backed providers this writes `keyRef` entries; for custom providers this writes `models.providers.<id>.apiKey` as an env ref (for example `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`).
Non-interactive `ref` mode contract:
- Set the provider env var in the onboarding process environment (for example `OPENAI_API_KEY`).
- Do not pass inline key flags (for example `--openai-api-key`) unless that env var is also set.
- If an inline key flag is passed without the required env var, onboarding fails fast with guidance.
Interactive onboarding behavior with reference mode:
- Choose **Use secret reference** when prompted.
- Then choose either:
- Environment variable
- Configured secret provider (`file` or `exec`)
- Onboarding performs a fast preflight validation before saving the ref.
- If validation fails, onboarding shows the error and lets you retry.
Non-interactive Z.AI endpoint choices:
Note: `--auth-choice zai-api-key` now auto-detects the best Z.AI endpoint for your key (prefers the general API with `zai/glm-5`).

View File

@@ -1,119 +0,0 @@
---
summary: "CLI reference for `openclaw secrets` (reload, audit, configure, apply)"
read_when:
- Re-resolving secret refs at runtime
- Auditing plaintext residues and unresolved refs
- Configuring SecretRefs and applying one-way scrub changes
title: "secrets"
---
# `openclaw secrets`
Secrets runtime controls.
Related:
- Secrets guide: [Secrets Management](/gateway/secrets)
- Security guide: [Security](/gateway/security)
## Reload runtime snapshot
Re-resolve secret refs and atomically swap runtime snapshot.
```bash
openclaw secrets reload
openclaw secrets reload --json
```
Notes:
- Uses gateway RPC method `secrets.reload`.
- If resolution fails, gateway keeps last-known-good snapshot.
- JSON response includes `warningCount`.
## Audit
Scan OpenClaw state for:
- plaintext secret storage
- unresolved refs
- precedence drift (`auth-profiles` shadowing config refs)
- legacy residues (`auth.json`, OAuth out-of-scope notes)
```bash
openclaw secrets audit
openclaw secrets audit --check
openclaw secrets audit --json
```
Exit behavior:
- `--check` exits non-zero on findings.
- unresolved refs exit with a higher-priority non-zero code.
## Configure (interactive helper)
Build provider + SecretRef changes interactively, run preflight, and optionally apply:
```bash
openclaw secrets configure
openclaw secrets configure --plan-out /tmp/openclaw-secrets-plan.json
openclaw secrets configure --apply --yes
openclaw secrets configure --providers-only
openclaw secrets configure --skip-provider-setup
openclaw secrets configure --json
```
Flow:
- Provider setup first (`add/edit/remove` for `secrets.providers` aliases).
- Credential mapping second (select fields and assign `{source, provider, id}` refs).
- Preflight and optional apply last.
Flags:
- `--providers-only`: configure `secrets.providers` only, skip credential mapping.
- `--skip-provider-setup`: skip provider setup and map credentials to existing providers.
Notes:
- `configure` targets secret-bearing fields in `openclaw.json`.
- Include all secret-bearing fields you intend to migrate (for example both `models.providers.*.apiKey` and `skills.entries.*.apiKey`) so audit can reach a clean state.
- It performs preflight resolution before apply.
- Apply path is one-way for migrated plaintext values.
Exec provider safety note:
- Homebrew installs often expose symlinked binaries under `/opt/homebrew/bin/*`.
- Set `allowSymlinkCommand: true` only when needed for trusted package-manager paths, and pair it with `trustedDirs` (for example `["/opt/homebrew"]`).
## Apply a saved plan
Apply or preflight a plan generated previously:
```bash
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --json
```
Plan contract details (allowed target paths, validation rules, and failure semantics):
- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)
## Why no rollback backups
`secrets apply` intentionally does not write rollback backups containing old plaintext values.
Safety comes from strict preflight + atomic-ish apply with best-effort in-memory restore on failure.
## Example
```bash
# Audit first, then configure, then confirm clean:
openclaw secrets audit --check
openclaw secrets configure
openclaw secrets audit --check
```
If `audit --check` still reports plaintext findings after a partial migration, verify you also migrated skill keys (`skills.entries.*.apiKey`) and any other reported target paths.

View File

@@ -98,9 +98,6 @@ sequenceDiagram
- **Local** connects (loopback or the gateway hosts own tailnet address) can be
autoapproved to keep samehost UX smooth.
- All connects must sign the `connect.challenge` nonce.
- Signature payload `v3` also binds `platform` + `deviceFamily`; the gateway
pins paired metadata on reconnect and requires repair pairing for metadata
changes.
- **Nonlocal** connects still require explicit approval.
- Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
remote.

View File

@@ -70,8 +70,6 @@ OpenClaw ships with the piai catalog. These providers require **no**
- Auth: OAuth (ChatGPT)
- Example model: `openai-codex/gpt-5.3-codex`
- CLI: `openclaw onboard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex`
- Default transport is `auto` (WebSocket-first, SSE fallback)
- Override per model via `agents.defaults.models["openai-codex/<model>"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`)
```json5
{

View File

@@ -40,9 +40,8 @@ To reduce that, OpenClaw treats `auth-profiles.json` as a **token sink**:
Secrets are stored **per-agent**:
- Auth profiles (OAuth + API keys + optional value-level refs): `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
- Legacy compatibility file: `~/.openclaw/agents/<agentId>/agent/auth.json`
(static `api_key` entries are scrubbed when discovered)
- Auth profiles (OAuth + API keys): `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
- Runtime cache (managed automatically; dont edit): `~/.openclaw/agents/<agentId>/agent/auth.json`
Legacy import-only file (still supported, but not the main store):
@@ -50,8 +49,6 @@ Legacy import-only file (still supported, but not the main store):
All of the above also respect `$OPENCLAW_STATE_DIR` (state dir override). Full reference: [/gateway/configuration](/gateway/configuration#auth-storage-oauth--api-keys)
For static secret refs and runtime snapshot activation behavior, see [Secrets Management](/gateway/secrets).
## Anthropic setup-token (subscription auth)
Run `claude setup-token` on any machine, then paste it into OpenClaw:

View File

@@ -1140,8 +1140,6 @@
"gateway/configuration-reference",
"gateway/configuration-examples",
"gateway/authentication",
"gateway/secrets",
"gateway/secrets-plan-contract",
"gateway/trusted-proxy-auth",
"gateway/health",
"gateway/heartbeat",
@@ -1237,7 +1235,6 @@
"cli/qr",
"cli/reset",
"cli/sandbox",
"cli/secrets",
"cli/security",
"cli/sessions",
"cli/setup",

View File

@@ -14,7 +14,6 @@ use the longlived token created by `claude setup-token`.
See [/concepts/oauth](/concepts/oauth) for the full OAuth flow and storage
layout.
For SecretRef-based auth (`env`/`file`/`exec` providers), see [Secrets Management](/gateway/secrets).
## Recommended Anthropic setup (API key)
@@ -86,11 +85,6 @@ openclaw models auth paste-token --provider anthropic
openclaw models auth paste-token --provider openrouter
```
Auth profile refs are also supported for static credentials:
- `api_key` credentials can use `keyRef: { source, provider, id }`
- `token` credentials can use `tokenRef: { source, provider, id }`
Automation-friendly check (exit `1` when expired/missing, `2` when expiring):
```bash

View File

@@ -321,7 +321,6 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
```
- Service account JSON: inline (`serviceAccount`) or file-based (`serviceAccountFile`).
- Service account SecretRef is also supported (`serviceAccountRef`).
- Env fallbacks: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE`.
- Use `spaces/<spaceId>` or `users/<userId>` for delivery targets.
- `channels.googlechat.dangerouslyAllowNameMatching` re-enables mutable email principal matching (break-glass compatibility mode).
@@ -1987,7 +1986,7 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.1 via LM Studio
},
entries: {
"nano-banana-pro": {
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string
apiKey: "GEMINI_KEY_HERE",
env: { GEMINI_API_KEY: "GEMINI_KEY_HERE" },
},
peekaboo: { enabled: true },
@@ -1999,7 +1998,7 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.1 via LM Studio
- `allowBundled`: optional allowlist for bundled skills only (managed/workspace skills unaffected).
- `entries.<skillKey>.enabled: false` disables a skill even if bundled/installed.
- `entries.<skillKey>.apiKey`: convenience for skills declaring a primary env var (plaintext string or SecretRef object).
- `entries.<skillKey>.apiKey`: convenience for skills declaring a primary env var.
---
@@ -2159,8 +2158,7 @@ See [Plugins](/tools/plugin).
- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins.
- `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy.
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
- `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves.
- Local gateway call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
- `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth.
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.
- `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior.
- `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list).
@@ -2386,73 +2384,6 @@ Reference env vars in any config string with `${VAR_NAME}`:
---
## Secrets
Secret refs are additive: plaintext values still work.
### `SecretRef`
Use one object shape:
```json5
{ source: "env" | "file" | "exec", provider: "default", id: "..." }
```
Validation:
- `provider` pattern: `^[a-z][a-z0-9_-]{0,63}$`
- `source: "env"` id pattern: `^[A-Z][A-Z0-9_]{0,127}$`
- `source: "file"` id: absolute JSON pointer (for example `"/providers/openai/apiKey"`)
- `source: "exec"` id pattern: `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`
### Supported fields in config
- `models.providers.<provider>.apiKey`
- `skills.entries.<skillKey>.apiKey`
- `channels.googlechat.serviceAccount`
- `channels.googlechat.serviceAccountRef`
- `channels.googlechat.accounts.<accountId>.serviceAccount`
- `channels.googlechat.accounts.<accountId>.serviceAccountRef`
### Secret providers config
```json5
{
secrets: {
providers: {
default: { source: "env" }, // optional explicit env provider
filemain: {
source: "file",
path: "~/.openclaw/secrets.json",
mode: "json",
timeoutMs: 5000,
},
vault: {
source: "exec",
command: "/usr/local/bin/openclaw-vault-resolver",
passEnv: ["PATH", "VAULT_ADDR"],
},
},
defaults: {
env: "default",
file: "filemain",
exec: "vault",
},
},
}
```
Notes:
- `file` provider supports `mode: "json"` and `mode: "singleValue"` (`id` must be `"value"` in singleValue mode).
- `exec` provider requires an absolute `command` path and uses protocol payloads on stdin/stdout.
- By default, symlink command paths are rejected. Set `allowSymlinkCommand: true` to allow symlink paths while validating the resolved target path.
- If `trustedDirs` is configured, the trusted-dir check applies to the resolved target path.
- `exec` child environment is minimal by default; pass required variables explicitly with `passEnv`.
- Secret refs are resolved at activation time into an in-memory snapshot, then request paths read the snapshot only.
---
## Auth storage
```json5
@@ -2470,11 +2401,8 @@ Notes:
```
- Per-agent auth profiles stored at `<agentDir>/auth-profiles.json`.
- Auth profiles support value-level refs (`keyRef` for `api_key`, `tokenRef` for `token`).
- Static runtime credentials come from in-memory resolved snapshots; legacy static `auth.json` entries are scrubbed when discovered.
- Legacy OAuth imports from `~/.openclaw/credentials/oauth.json`.
- See [OAuth](/concepts/oauth).
- Secrets runtime behavior and `audit/configure/apply` tooling: [Secrets Management](/gateway/secrets).
---

View File

@@ -492,42 +492,6 @@ Rules:
</Accordion>
<Accordion title="Secret refs (env, file, exec)">
For fields that support SecretRef objects, you can use:
```json5
{
models: {
providers: {
openai: { apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" } },
},
},
skills: {
entries: {
"nano-banana-pro": {
apiKey: {
source: "file",
provider: "filemain",
id: "/skills/entries/nano-banana-pro/apiKey",
},
},
},
},
channels: {
googlechat: {
serviceAccountRef: {
source: "exec",
provider: "vault",
id: "channels/googlechat/serviceAccount",
},
},
},
}
```
SecretRef details (including `secrets.providers` for `env`/`file`/`exec`) are in [Secrets Management](/gateway/secrets).
</Accordion>
See [Environment](/help/environment) for full precedence and sources.
## Full reference

View File

@@ -16,12 +16,6 @@ Use this page for day-1 startup and day-2 operations of the Gateway service.
<Card title="Configuration" icon="sliders" href="/gateway/configuration">
Task-oriented setup guide + full configuration reference.
</Card>
<Card title="Secrets management" icon="key-round" href="/gateway/secrets">
SecretRef contract, runtime snapshot behavior, and migrate/reload operations.
</Card>
<Card title="Secrets plan contract" icon="shield-check" href="/gateway/secrets-plan-contract">
Exact `secrets apply` target/path rules and ref-only auth-profile behavior.
</Card>
</CardGroup>
## 5-minute local startup
@@ -100,7 +94,6 @@ openclaw gateway status --json
openclaw gateway install
openclaw gateway restart
openclaw gateway stop
openclaw secrets reload
openclaw logs --follow
openclaw doctor
```

View File

@@ -215,10 +215,6 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth`
is enabled for break-glass use.
- All connections must sign the server-provided `connect.challenge` nonce.
- Preferred signature payload is `v3`, which binds `platform` and `deviceFamily`
in addition to device/client/role/scopes/token/nonce fields.
- Legacy `v2` signatures remain accepted for compatibility, but paired-device
metadata pinning still controls command policy on reconnect.
## TLS + pinning

View File

@@ -107,8 +107,8 @@ Gateway call/probe credential resolution now follows one shared contract:
- Explicit credentials (`--token`, `--password`, or tool `gatewayToken`) always win.
- Local mode defaults:
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` -> `gateway.remote.token`
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password`
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token`
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password`
- Remote mode defaults:
- token: `gateway.remote.token` -> `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token`
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.remote.password` -> `gateway.auth.password`
@@ -134,8 +134,7 @@ Short version: **keep the Gateway loopback-only** unless youre sure you need
- **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure).
- **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use auth tokens/passwords.
- `gateway.remote.token` / `.password` are client credential sources. They do **not** configure server auth by themselves.
- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
- `gateway.remote.token` is **only** for remote CLI calls — it does **not** enable local auth.
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
- **Tailscale Serve** can authenticate Control UI/WebSocket traffic via identity
headers when `gateway.auth.allowTailscale: true`; HTTP API endpoints still

View File

@@ -1,94 +0,0 @@
---
summary: "Contract for `secrets apply` plans: allowed target paths, validation, and ref-only auth-profile behavior"
read_when:
- Generating or reviewing `openclaw secrets apply` plan files
- Debugging `Invalid plan target path` errors
- Understanding how `keyRef` and `tokenRef` influence implicit provider discovery
title: "Secrets Apply Plan Contract"
---
# Secrets apply plan contract
This page defines the strict contract enforced by `openclaw secrets apply`.
If a target does not match these rules, apply fails before mutating config.
## Plan file shape
`openclaw secrets apply --from <plan.json>` expects a `targets` array of plan targets:
```json5
{
version: 1,
protocolVersion: 1,
targets: [
{
type: "models.providers.apiKey",
path: "models.providers.openai.apiKey",
pathSegments: ["models", "providers", "openai", "apiKey"],
providerId: "openai",
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
],
}
```
## Allowed target types and paths
| `target.type` | Allowed `target.path` shape | Optional id match rule |
| ------------------------------------ | --------------------------------------------------------- | --------------------------------------------------- |
| `models.providers.apiKey` | `models.providers.<providerId>.apiKey` | `providerId` must match `<providerId>` when present |
| `skills.entries.apiKey` | `skills.entries.<skillKey>.apiKey` | n/a |
| `channels.googlechat.serviceAccount` | `channels.googlechat.serviceAccount` | `accountId` must be empty/omitted |
| `channels.googlechat.serviceAccount` | `channels.googlechat.accounts.<accountId>.serviceAccount` | `accountId` must match `<accountId>` when present |
## Path validation rules
Each target is validated with all of the following:
- `type` must be one of the allowed target types above.
- `path` must be a non-empty dot path.
- `pathSegments` can be omitted. If provided, it must normalize to exactly the same path as `path`.
- Forbidden segments are rejected: `__proto__`, `prototype`, `constructor`.
- The normalized path must match one of the allowed path shapes for the target type.
- If `providerId` / `accountId` is set, it must match the id encoded in the path.
## Failure behavior
If a target fails validation, apply exits with an error like:
```text
Invalid plan target path for models.providers.apiKey: models.providers.openai.baseUrl
```
No partial mutation is committed for that invalid target path.
## Ref-only auth profiles and implicit providers
Implicit provider discovery also considers auth profiles that store refs instead of plaintext credentials:
- `type: "api_key"` profiles can use `keyRef` (for example env-backed refs).
- `type: "token"` profiles can use `tokenRef`.
Behavior:
- For API-key providers (for example `volcengine`, `byteplus`), ref-only profiles can still activate implicit provider entries.
- For `github-copilot`, if the profile has no plaintext token, discovery will try `tokenRef` env resolution before token exchange.
## Operator checks
```bash
# Validate plan without writes
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
# Then apply for real
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
```
If apply fails with an invalid target path message, regenerate the plan with `openclaw secrets configure` or fix the target path to one of the allowed shapes above.
## Related docs
- [Secrets Management](/gateway/secrets)
- [CLI `secrets`](/cli/secrets)
- [Configuration Reference](/gateway/configuration-reference)

View File

@@ -1,386 +0,0 @@
---
summary: "Secrets management: SecretRef contract, runtime snapshot behavior, and safe one-way scrubbing"
read_when:
- Configuring SecretRefs for providers, auth profiles, skills, or Google Chat
- Operating secrets reload/audit/configure/apply safely in production
- Understanding fail-fast and last-known-good behavior
title: "Secrets Management"
---
# Secrets management
OpenClaw supports additive secret references so credentials do not need to be stored as plaintext in config files.
Plaintext still works. Secret refs are optional.
## Goals and runtime model
Secrets are resolved into an in-memory runtime snapshot.
- Resolution is eager during activation, not lazy on request paths.
- Startup fails fast if any referenced credential cannot be resolved.
- Reload uses atomic swap: full success or keep last-known-good.
- Runtime requests read from the active in-memory snapshot.
This keeps secret-provider outages off the hot request path.
## Onboarding reference preflight
When onboarding runs in interactive mode and you choose secret reference storage, OpenClaw performs a fast preflight check before saving:
- Env refs: validates env var name and confirms a non-empty value is visible during onboarding.
- Provider refs (`file` or `exec`): validates the selected provider, resolves the provided `id`, and checks value type.
If validation fails, onboarding shows the error and lets you retry.
## SecretRef contract
Use one object shape everywhere:
```json5
{ source: "env" | "file" | "exec", provider: "default", id: "..." }
```
### `source: "env"`
```json5
{ source: "env", provider: "default", id: "OPENAI_API_KEY" }
```
Validation:
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
- `id` must match `^[A-Z][A-Z0-9_]{0,127}$`
### `source: "file"`
```json5
{ source: "file", provider: "filemain", id: "/providers/openai/apiKey" }
```
Validation:
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
- `id` must be an absolute JSON pointer (`/...`)
- RFC6901 escaping in segments: `~` => `~0`, `/` => `~1`
### `source: "exec"`
```json5
{ source: "exec", provider: "vault", id: "providers/openai/apiKey" }
```
Validation:
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
- `id` must match `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`
## Provider config
Define providers under `secrets.providers`:
```json5
{
secrets: {
providers: {
default: { source: "env" },
filemain: {
source: "file",
path: "~/.openclaw/secrets.json",
mode: "json", // or "singleValue"
},
vault: {
source: "exec",
command: "/usr/local/bin/openclaw-vault-resolver",
args: ["--profile", "prod"],
passEnv: ["PATH", "VAULT_ADDR"],
jsonOnly: true,
},
},
defaults: {
env: "default",
file: "filemain",
exec: "vault",
},
resolution: {
maxProviderConcurrency: 4,
maxRefsPerProvider: 512,
maxBatchBytes: 262144,
},
},
}
```
### Env provider
- Optional allowlist via `allowlist`.
- Missing/empty env values fail resolution.
### File provider
- Reads local file from `path`.
- `mode: "json"` expects JSON object payload and resolves `id` as pointer.
- `mode: "singleValue"` expects ref id `"value"` and returns file contents.
- Path must pass ownership/permission checks.
### Exec provider
- Runs configured absolute binary path, no shell.
- By default, `command` must point to a regular file (not a symlink).
- Set `allowSymlinkCommand: true` to allow symlink command paths (for example Homebrew shims). OpenClaw validates the resolved target path.
- Enable `allowSymlinkCommand` only when required for trusted package-manager paths, and pair it with `trustedDirs` (for example `["/opt/homebrew"]`).
- When `trustedDirs` is set, checks apply to the resolved target path.
- Supports timeout, no-output timeout, output byte limits, env allowlist, and trusted dirs.
- Request payload (stdin):
```json
{ "protocolVersion": 1, "provider": "vault", "ids": ["providers/openai/apiKey"] }
```
- Response payload (stdout):
```json
{ "protocolVersion": 1, "values": { "providers/openai/apiKey": "sk-..." } }
```
Optional per-id errors:
```json
{
"protocolVersion": 1,
"values": {},
"errors": { "providers/openai/apiKey": { "message": "not found" } }
}
```
## Exec integration examples
### 1Password CLI
```json5
{
secrets: {
providers: {
onepassword_openai: {
source: "exec",
command: "/opt/homebrew/bin/op",
allowSymlinkCommand: true, // required for Homebrew symlinked binaries
trustedDirs: ["/opt/homebrew"],
args: ["read", "op://Personal/OpenClaw QA API Key/password"],
passEnv: ["HOME"],
jsonOnly: false,
},
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [{ id: "gpt-5", name: "gpt-5" }],
apiKey: { source: "exec", provider: "onepassword_openai", id: "value" },
},
},
},
}
```
### HashiCorp Vault CLI
```json5
{
secrets: {
providers: {
vault_openai: {
source: "exec",
command: "/opt/homebrew/bin/vault",
allowSymlinkCommand: true, // required for Homebrew symlinked binaries
trustedDirs: ["/opt/homebrew"],
args: ["kv", "get", "-field=OPENAI_API_KEY", "secret/openclaw"],
passEnv: ["VAULT_ADDR", "VAULT_TOKEN"],
jsonOnly: false,
},
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [{ id: "gpt-5", name: "gpt-5" }],
apiKey: { source: "exec", provider: "vault_openai", id: "value" },
},
},
},
}
```
### `sops`
```json5
{
secrets: {
providers: {
sops_openai: {
source: "exec",
command: "/opt/homebrew/bin/sops",
allowSymlinkCommand: true, // required for Homebrew symlinked binaries
trustedDirs: ["/opt/homebrew"],
args: ["-d", "--extract", '["providers"]["openai"]["apiKey"]', "/path/to/secrets.enc.json"],
passEnv: ["SOPS_AGE_KEY_FILE"],
jsonOnly: false,
},
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [{ id: "gpt-5", name: "gpt-5" }],
apiKey: { source: "exec", provider: "sops_openai", id: "value" },
},
},
},
}
```
## In-scope fields (v1)
### `~/.openclaw/openclaw.json`
- `models.providers.<provider>.apiKey`
- `skills.entries.<skillKey>.apiKey`
- `channels.googlechat.serviceAccount`
- `channels.googlechat.serviceAccountRef`
- `channels.googlechat.accounts.<accountId>.serviceAccount`
- `channels.googlechat.accounts.<accountId>.serviceAccountRef`
### `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
- `profiles.<profileId>.keyRef` for `type: "api_key"`
- `profiles.<profileId>.tokenRef` for `type: "token"`
OAuth credential storage changes are out of scope.
## Required behavior and precedence
- Field without ref: unchanged.
- Field with ref: required at activation time.
- If plaintext and ref both exist, ref wins at runtime and plaintext is ignored.
Warning code:
- `SECRETS_REF_OVERRIDES_PLAINTEXT`
## Activation triggers
Secret activation is attempted on:
- Startup (preflight plus final activation)
- Config reload hot-apply path
- Config reload restart-check path
- Manual reload via `secrets.reload`
Activation contract:
- Success swaps the snapshot atomically.
- Startup failure aborts gateway startup.
- Runtime reload failure keeps last-known-good snapshot.
## Degraded and recovered operator signals
When reload-time activation fails after a healthy state, OpenClaw enters degraded secrets state.
One-shot system event and log codes:
- `SECRETS_RELOADER_DEGRADED`
- `SECRETS_RELOADER_RECOVERED`
Behavior:
- Degraded: runtime keeps last-known-good snapshot.
- Recovered: emitted once after a successful activation.
- Repeated failures while already degraded log warnings but do not spam events.
- Startup fail-fast does not emit degraded events because no runtime snapshot exists yet.
## Audit and configure workflow
Use this default operator flow:
```bash
openclaw secrets audit --check
openclaw secrets configure
openclaw secrets audit --check
```
Migration completeness:
- Include `skills.entries.<skillKey>.apiKey` targets when those skills use API keys.
- If `audit --check` still reports plaintext findings after a partial migration, migrate the remaining reported paths and rerun audit.
### `secrets audit`
Findings include:
- plaintext values at rest (`openclaw.json`, `auth-profiles.json`, `.env`)
- unresolved refs
- precedence shadowing (`auth-profiles` taking priority over config refs)
- legacy residues (`auth.json`, OAuth out-of-scope reminders)
### `secrets configure`
Interactive helper that:
- configures `secrets.providers` first (`env`/`file`/`exec`, add/edit/remove)
- lets you select secret-bearing fields in `openclaw.json`
- captures SecretRef details (`source`, `provider`, `id`)
- runs preflight resolution
- can apply immediately
Helpful modes:
- `openclaw secrets configure --providers-only`
- `openclaw secrets configure --skip-provider-setup`
`configure` apply defaults to:
- scrub matching static creds from `auth-profiles.json` for targeted providers
- scrub legacy static `api_key` entries from `auth.json`
- scrub matching known secret lines from `<config-dir>/.env`
### `secrets apply`
Apply a saved plan:
```bash
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
```
For strict target/path contract details and exact rejection rules, see:
- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)
## One-way safety policy
OpenClaw intentionally does **not** write rollback backups that contain pre-migration plaintext secret values.
Safety model:
- preflight must succeed before write mode
- runtime activation is validated before commit
- apply updates files using atomic file replacement and best-effort in-memory restore on failure
## `auth.json` compatibility notes
For static credentials, OpenClaw runtime no longer depends on plaintext `auth.json`.
- Runtime credential source is the resolved in-memory snapshot.
- Legacy `auth.json` static `api_key` entries are scrubbed when discovered.
- OAuth-related legacy compatibility behavior remains separate.
## Related docs
- CLI commands: [secrets](/cli/secrets)
- Plan contract details: [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)
- Auth setup: [Authentication](/gateway/authentication)
- Security posture: [Security](/gateway/security)
- Environment precedence: [Environment Variables](/help/environment)

View File

@@ -206,7 +206,6 @@ Use this when auditing access or deciding what to back up:
- `~/.openclaw/credentials/<channel>-allowFrom.json` (default account)
- `~/.openclaw/credentials/<channel>-<accountId>-allowFrom.json` (non-default accounts)
- **Model auth profiles**: `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
- **File-backed secrets payload (optional)**: `~/.openclaw/secrets.json`
- **Legacy OAuth import**: `~/.openclaw/credentials/oauth.json`
## Security Audit Checklist
@@ -686,10 +685,8 @@ Set a token so **all** WS clients must authenticate:
Doctor can generate one for you: `openclaw doctor --generate-gateway-token`.
Note: `gateway.remote.token` / `.password` are client credential sources. They
do **not** protect local WS access by themselves.
Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*`
is unset.
Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
protect local WS access.
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
Local device pairing:
@@ -763,9 +760,7 @@ Assume anything under `~/.openclaw/` (or `$OPENCLAW_STATE_DIR/`) may contain sec
- `openclaw.json`: config may include tokens (gateway, remote gateway), provider settings, and allowlists.
- `credentials/**`: channel credentials (example: WhatsApp creds), pairing allowlists, legacy OAuth imports.
- `agents/<agentId>/agent/auth-profiles.json`: API keys, token profiles, OAuth tokens, and optional `keyRef`/`tokenRef`.
- `secrets.json` (optional): file-backed secret payload used by `file` SecretRef providers (`secrets.providers`).
- `agents/<agentId>/agent/auth.json`: legacy compatibility file. Static `api_key` entries are scrubbed when discovered.
- `agents/<agentId>/agent/auth-profiles.json`: API keys + OAuth tokens (imported from legacy `credentials/oauth.json`).
- `agents/<agentId>/sessions/**`: session transcripts (`*.jsonl`) + routing metadata (`sessions.json`) that can contain private messages and tool output.
- `extensions/**`: installed plugins (plus their `node_modules/`).
- `sandboxes/**`: tool sandbox workspaces; can accumulate copies of files you read/write inside the sandbox.
@@ -1064,7 +1059,7 @@ If your AI does something bad:
1. Rotate Gateway auth (`gateway.auth.token` / `OPENCLAW_GATEWAY_PASSWORD`) and restart.
2. Rotate remote client secrets (`gateway.remote.token` / `.password`) on any machine that can call the Gateway.
3. Rotate provider/API credentials (WhatsApp creds, Slack/Discord tokens, model/API keys in `auth-profiles.json`, and encrypted secrets payload values when used).
3. Rotate provider/API credentials (WhatsApp creds, Slack/Discord tokens, model/API keys in `auth-profiles.json`).
### Audit

View File

@@ -74,15 +74,6 @@ You can reference env vars directly in config string values using `${VAR_NAME}`
See [Configuration: Env var substitution](/gateway/configuration#env-var-substitution-in-config) for full details.
## Secret refs vs `${ENV}` strings
OpenClaw supports two env-driven patterns:
- `${VAR}` string substitution in config values.
- SecretRef objects (`{ source: "env", provider: "default", id: "VAR" }`) for fields that support secrets references.
Both resolve from process env at activation time. SecretRef details are documented in [Secrets Management](/gateway/secrets).
## Path-related env vars
| Variable | Purpose |

View File

@@ -1291,17 +1291,16 @@ Related: [Agent workspace](/concepts/agent-workspace), [Memory](/concepts/memory
Everything lives under `$OPENCLAW_STATE_DIR` (default: `~/.openclaw`):
| Path | Purpose |
| --------------------------------------------------------------- | ------------------------------------------------------------------ |
| `$OPENCLAW_STATE_DIR/openclaw.json` | Main config (JSON5) |
| `$OPENCLAW_STATE_DIR/credentials/oauth.json` | Legacy OAuth import (copied into auth profiles on first use) |
| `$OPENCLAW_STATE_DIR/agents/<agentId>/agent/auth-profiles.json` | Auth profiles (OAuth, API keys, and optional `keyRef`/`tokenRef`) |
| `$OPENCLAW_STATE_DIR/secrets.json` | Optional file-backed secret payload for `file` SecretRef providers |
| `$OPENCLAW_STATE_DIR/agents/<agentId>/agent/auth.json` | Legacy compatibility file (static `api_key` entries scrubbed) |
| `$OPENCLAW_STATE_DIR/credentials/` | Provider state (e.g. `whatsapp/<accountId>/creds.json`) |
| `$OPENCLAW_STATE_DIR/agents/` | Per-agent state (agentDir + sessions) |
| `$OPENCLAW_STATE_DIR/agents/<agentId>/sessions/` | Conversation history & state (per agent) |
| `$OPENCLAW_STATE_DIR/agents/<agentId>/sessions/sessions.json` | Session metadata (per agent) |
| Path | Purpose |
| --------------------------------------------------------------- | ------------------------------------------------------------ |
| `$OPENCLAW_STATE_DIR/openclaw.json` | Main config (JSON5) |
| `$OPENCLAW_STATE_DIR/credentials/oauth.json` | Legacy OAuth import (copied into auth profiles on first use) |
| `$OPENCLAW_STATE_DIR/agents/<agentId>/agent/auth-profiles.json` | Auth profiles (OAuth + API keys) |
| `$OPENCLAW_STATE_DIR/agents/<agentId>/agent/auth.json` | Runtime auth cache (managed automatically) |
| `$OPENCLAW_STATE_DIR/credentials/` | Provider state (e.g. `whatsapp/<accountId>/creds.json`) |
| `$OPENCLAW_STATE_DIR/agents/` | Per-agent state (agentDir + sessions) |
| `$OPENCLAW_STATE_DIR/agents/<agentId>/sessions/` | Conversation history & state (per agent) |
| `$OPENCLAW_STATE_DIR/agents/<agentId>/sessions/sessions.json` | Session metadata (per agent) |
Legacy single-agent path: `~/.openclaw/agent/*` (migrated by `openclaw doctor`).
@@ -1339,7 +1338,7 @@ Put your **agent workspace** in a **private** git repo and back it up somewhere
private (for example GitHub private). This captures memory + AGENTS/SOUL/USER
files, and lets you restore the assistant's "mind" later.
Do **not** commit anything under `~/.openclaw` (credentials, sessions, tokens, or encrypted secrets payloads).
Do **not** commit anything under `~/.openclaw` (credentials, sessions, tokens).
If you need a full restore, back up both the workspace and the state directory
separately (see the migration question above).
@@ -1405,8 +1404,7 @@ Non-loopback binds **require auth**. Configure `gateway.auth.mode` + `gateway.au
Notes:
- `gateway.remote.token` / `.password` do **not** enable local gateway auth by themselves.
- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
- `gateway.remote.token` is for **remote CLI calls** only; it does not enable local gateway auth.
- The Control UI authenticates via `connect.params.auth.token` (stored in app/UI settings). Avoid putting tokens in URLs.
### Why do I need a token on localhost now

View File

@@ -85,7 +85,6 @@ To add quadlet **after** an initial setup that did not use it, re-run: `./setup-
- **Token:** Stored in `~openclaw/.openclaw/.env` as `OPENCLAW_GATEWAY_TOKEN`. `setup-podman.sh` and `run-openclaw-podman.sh` generate it if missing (uses `openssl`, `python3`, or `od`).
- **Optional:** In that `.env` you can set provider keys (e.g. `GROQ_API_KEY`, `OLLAMA_API_KEY`) and other OpenClaw env vars.
- **Host ports:** By default the script maps `18789` (gateway) and `18790` (bridge). Override the **host** port mapping with `OPENCLAW_PODMAN_GATEWAY_HOST_PORT` and `OPENCLAW_PODMAN_BRIDGE_HOST_PORT` when launching.
- **Gateway bind:** By default, `run-openclaw-podman.sh` starts the gateway with `--bind loopback` for safe local access. To expose on LAN, set `OPENCLAW_GATEWAY_BIND=lan` and configure `gateway.controlUi.allowedOrigins` (or explicitly enable host-header fallback) in `openclaw.json`.
- **Paths:** Host config and workspace default to `~openclaw/.openclaw` and `~openclaw/.openclaw/workspace`. Override the host paths used by the launch script with `OPENCLAW_CONFIG_DIR` and `OPENCLAW_WORKSPACE_DIR`.
## Useful commands

View File

@@ -232,10 +232,6 @@ await session.prompt(effectivePrompt, { images: imageResult.images });
The SDK handles the full agent loop: sending to LLM, executing tool calls, streaming responses.
Image injection is prompt-local: OpenClaw loads image refs from the current prompt and
passes them via `images` for that turn only. It does not re-scan older history turns
to re-inject image payloads.
## Tool Architecture
### Tool Pipeline

View File

@@ -56,33 +56,6 @@ openclaw models auth login --provider openai-codex
}
```
### Codex transport default
OpenClaw uses `pi-ai` for model streaming. For `openai-codex/*` models you can set
`agents.defaults.models.<provider/model>.params.transport` to select transport:
- Default is `"auto"` (WebSocket-first, then SSE fallback).
- `"sse"`: force SSE
- `"websocket"`: force WebSocket
- `"auto"`: try WebSocket, then fall back to SSE
```json5
{
agents: {
defaults: {
model: { primary: "openai-codex/gpt-5.3-codex" },
models: {
"openai-codex/gpt-5.3-codex": {
params: {
transport: "auto",
},
},
},
},
},
}
```
## Notes
- Model refs always use `provider/model` (see [/concepts/models](/concepts/models)).

View File

@@ -20,8 +20,6 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
- If `~/.openclaw/openclaw.json` exists, choose **Keep / Modify / Reset**.
- Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset**
(or pass `--reset`).
- CLI `--reset` defaults to `config+creds+sessions`; use `--reset-scope full`
to also remove workspace.
- If the config is invalid or contains legacy keys, the wizard stops and asks
you to run `openclaw doctor` before continuing.
- Reset uses `trash` (never `rm`) and offers scopes:
@@ -36,7 +34,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
- **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
- **OpenAI Code (Codex) subscription (OAuth)**: browser flow; paste the `code#state`.
- Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then stores it in auth profiles.
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.openclaw/.env` so launchd can read it.
- **xAI (Grok) API key**: prompts for `XAI_API_KEY` and configures xAI as a model provider.
- **OpenCode Zen (multi-model proxy)**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth).
- **API key**: stores the key for you.
@@ -54,7 +52,6 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
- **Skip**: no auth configured yet.
- Pick a default model from detected options (or enter provider/model manually).
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
- API key storage mode defaults to plaintext auth-profile values. Use `--secret-input-mode ref` to store env-backed refs instead (for example `keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }`).
- OAuth credentials live in `~/.openclaw/credentials/oauth.json`; auth profiles live in `~/.openclaw/agents/<agentId>/agent/auth-profiles.json` (API keys + OAuth).
- More detail: [/concepts/oauth](/concepts/oauth)
<Note>

View File

@@ -134,7 +134,6 @@ Use this when debugging auth or deciding what to back up:
- `~/.openclaw/credentials/<channel>-allowFrom.json` (default account)
- `~/.openclaw/credentials/<channel>-<accountId>-allowFrom.json` (non-default accounts)
- **Model auth profiles**: `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
- **File-backed secrets payload (optional)**: `~/.openclaw/secrets.json`
- **Legacy OAuth import**: `~/.openclaw/credentials/oauth.json`
More detail: [Security](/gateway/security#credential-storage-map).

View File

@@ -22,7 +22,6 @@ openclaw onboard --non-interactive \
--mode local \
--auth-choice apiKey \
--anthropic-api-key "$ANTHROPIC_API_KEY" \
--secret-input-mode plaintext \
--gateway-port 18789 \
--gateway-bind loopback \
--install-daemon \
@@ -32,22 +31,6 @@ openclaw onboard --non-interactive \
Add `--json` for a machine-readable summary.
Use `--secret-input-mode ref` to store env-backed refs in auth profiles instead of plaintext values.
Interactive selection between env refs and configured provider refs (`file` or `exec`) is available in the onboarding wizard flow.
In non-interactive `ref` mode, provider env vars must be set in the process environment.
Passing inline key flags without the matching env var now fails fast.
Example:
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice openai-api-key \
--secret-input-mode ref \
--accept-risk
```
## Provider-specific examples
<AccordionGroup>
@@ -149,24 +132,6 @@ openclaw onboard --non-interactive \
`--custom-api-key` is optional. If omitted, onboarding checks `CUSTOM_API_KEY`.
Ref-mode variant:
```bash
export CUSTOM_API_KEY="your-key"
openclaw onboard --non-interactive \
--mode local \
--auth-choice custom-api-key \
--custom-base-url "https://llm.example.com/v1" \
--custom-model-id "foo-large" \
--secret-input-mode ref \
--custom-provider-id "my-custom" \
--custom-compatibility anthropic \
--gateway-port 18789 \
--gateway-bind loopback
```
In this mode, onboarding stores `apiKey` as `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`.
</Accordion>
</AccordionGroup>

View File

@@ -33,7 +33,6 @@ It does not install or modify anything on the remote host.
<Step title="Existing config detection">
- If `~/.openclaw/openclaw.json` exists, choose Keep, Modify, or Reset.
- Re-running the wizard does not wipe anything unless you explicitly choose Reset (or pass `--reset`).
- CLI `--reset` defaults to `config+creds+sessions`; use `--reset-scope full` to also remove workspace.
- If config is invalid or contains legacy keys, the wizard stops and asks you to run `openclaw doctor` before continuing.
- Reset uses `trash` and offers scopes:
- Config only
@@ -140,7 +139,8 @@ What you set:
</Accordion>
<Accordion title="OpenAI API key">
Uses `OPENAI_API_KEY` if present or prompts for a key, then stores the credential in auth profiles.
Uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to
`~/.openclaw/.env` so launchd can read it.
Sets `agents.defaults.model` to `openai/gpt-5.1-codex` when model is unset, `openai/*`, or `openai-codex/*`.
@@ -178,10 +178,6 @@ What you set:
<Accordion title="Custom provider">
Works with OpenAI-compatible and Anthropic-compatible endpoints.
Interactive onboarding supports the same API key storage choices as other provider API key flows:
- **Paste API key now** (plaintext)
- **Use secret reference** (env ref or configured provider ref, with preflight validation)
Non-interactive flags:
- `--auth-choice custom-api-key`
- `--custom-base-url`
@@ -206,24 +202,6 @@ Credential and profile paths:
- OAuth credentials: `~/.openclaw/credentials/oauth.json`
- Auth profiles (API keys + OAuth): `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
API key storage mode:
- Default onboarding behavior persists API keys as plaintext values in auth profiles.
- `--secret-input-mode ref` enables reference mode instead of plaintext key storage.
In interactive onboarding, you can choose either:
- environment variable ref (for example `keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }`)
- configured provider ref (`file` or `exec`) with provider alias + id
- Interactive reference mode runs a fast preflight validation before saving.
- Env refs: validates variable name + non-empty value in the current onboarding environment.
- Provider refs: validates provider config and resolves the requested id.
- If preflight fails, onboarding shows the error and lets you retry.
- In non-interactive mode, `--secret-input-mode ref` is env-backed only.
- Set the provider env var in the onboarding process environment.
- Inline key flags (for example `--openai-api-key`) require that env var to be set; otherwise onboarding fails fast.
- For custom providers, non-interactive `ref` mode stores `models.providers.<id>.apiKey` as `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`.
- In that custom-provider case, `--custom-api-key` requires `CUSTOM_API_KEY` to be set; otherwise onboarding fails fast.
- Existing plaintext setups continue to work unchanged.
<Note>
Headless and server tip: complete OAuth on a machine with a browser, then copy
`~/.openclaw/credentials/oauth.json` (or `$OPENCLAW_STATE_DIR/credentials/oauth.json`)

View File

@@ -65,9 +65,6 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
1. **Model/Auth** — Anthropic API key (recommended), OpenAI, or Custom Provider
(OpenAI-compatible, Anthropic-compatible, or Unknown auto-detect). Pick a default model.
For non-interactive runs, `--secret-input-mode ref` stores env-backed refs in auth profiles instead of plaintext API key values.
In non-interactive `ref` mode, the provider env var must be set; passing inline key flags without that env var fails fast.
In interactive runs, choosing secret reference mode lets you point at either an environment variable or a configured provider ref (`file` or `exec`), with a fast preflight validation before saving.
2. **Workspace** — Location for agent files (default `~/.openclaw/workspace`). Seeds bootstrap files.
3. **Gateway** — Port, bind address, auth mode, Tailscale exposure.
4. **Channels** — WhatsApp, Telegram, Discord, Google Chat, Mattermost, Signal, BlueBubbles, or iMessage.
@@ -77,7 +74,6 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
<Note>
Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset** (or pass `--reset`).
CLI `--reset` defaults to config, credentials, and sessions; use `--reset-scope full` to include workspace.
If the config is invalid or contains legacy keys, the wizard asks you to run `openclaw doctor` first.
</Note>

View File

@@ -4,7 +4,6 @@ read_when:
- Running coding harnesses through ACP
- Setting up thread-bound ACP sessions on thread-capable channels
- Troubleshooting ACP backend and plugin wiring
- Operating /acp commands from chat
title: "ACP Agents"
---
@@ -14,25 +13,6 @@ ACP sessions let OpenClaw run external coding harnesses (for example Pi, Claude
If you ask OpenClaw in plain language to "run this in Codex" or "start Claude Code in a thread", OpenClaw should route that request to the ACP runtime (not the native sub-agent runtime).
## Fast operator flow
Use this when you want a practical `/acp` runbook:
1. Spawn a session:
- `/acp spawn codex --mode persistent --thread auto`
2. Work in the bound thread (or target that session key explicitly).
3. Check runtime state:
- `/acp status`
4. Tune runtime options as needed:
- `/acp model <provider/model>`
- `/acp permissions <profile>`
- `/acp timeout <seconds>`
5. Nudge an active session without replacing context:
- `/acp steer tighten logging and continue`
6. Stop work:
- `/acp cancel` (stop current turn), or
- `/acp close` (close session + remove bindings)
## Quick start for humans
Examples of natural requests:
@@ -139,36 +119,6 @@ Key flags:
See [Slash Commands](/tools/slash-commands).
## Session target resolution
Most `/acp` actions accept an optional session target (`session-key`, `session-id`, or `session-label`).
Resolution order:
1. Explicit target argument (or `--session` for `/acp steer`)
- tries key
- then UUID-shaped session id
- then label
2. Current thread binding (if this conversation/thread is bound to an ACP session)
3. Current requester session fallback
If no target resolves, OpenClaw returns a clear error (`Unable to resolve session target: ...`).
## Spawn thread modes
`/acp spawn` supports `--thread auto|here|off`.
| Mode | Behavior |
| ------ | --------------------------------------------------------------------------------------------------- |
| `auto` | In an active thread: bind that thread. Outside a thread: create/bind a child thread when supported. |
| `here` | Require current active thread; fail if not in one. |
| `off` | No binding. Session starts unbound. |
Notes:
- On non-thread binding surfaces, default behavior is effectively `off`.
- Thread-bound spawn requires channel policy support (for Discord: `channels.discord.threadBindings.spawnAcpSessions=true`).
## ACP controls
Available command family:
@@ -193,40 +143,6 @@ Available command family:
Some controls depend on backend capabilities. If a backend does not support a control, OpenClaw returns a clear unsupported-control error.
## ACP command cookbook
| Command | What it does | Example |
| -------------------- | --------------------------------------------------------- | -------------------------------------------------------------- |
| `/acp spawn` | Create ACP session; optional thread bind. | `/acp spawn codex --mode persistent --thread auto --cwd /repo` |
| `/acp cancel` | Cancel in-flight turn for target session. | `/acp cancel agent:codex:acp:<uuid>` |
| `/acp steer` | Send steer instruction to running session. | `/acp steer --session support inbox prioritize failing tests` |
| `/acp close` | Close session and unbind thread targets. | `/acp close` |
| `/acp status` | Show backend, mode, state, runtime options, capabilities. | `/acp status` |
| `/acp set-mode` | Set runtime mode for target session. | `/acp set-mode plan` |
| `/acp set` | Generic runtime config option write. | `/acp set model openai/gpt-5.2` |
| `/acp cwd` | Set runtime working directory override. | `/acp cwd /Users/user/Projects/repo` |
| `/acp permissions` | Set approval policy profile. | `/acp permissions strict` |
| `/acp timeout` | Set runtime timeout (seconds). | `/acp timeout 120` |
| `/acp model` | Set runtime model override. | `/acp model anthropic/claude-opus-4-5` |
| `/acp reset-options` | Remove session runtime option overrides. | `/acp reset-options` |
| `/acp sessions` | List recent ACP sessions from store. | `/acp sessions` |
| `/acp doctor` | Backend health, capabilities, actionable fixes. | `/acp doctor` |
| `/acp install` | Print deterministic install and enable steps. | `/acp install` |
## Runtime options mapping
`/acp` has convenience commands and a generic setter.
Equivalent operations:
- `/acp model <id>` maps to runtime config key `model`.
- `/acp permissions <profile>` maps to runtime config key `approval_policy`.
- `/acp timeout <seconds>` maps to runtime config key `timeout`.
- `/acp cwd <path>` updates runtime cwd override directly.
- `/acp set <key> <value>` is the generic path.
- Special case: `key=cwd` uses the cwd override path.
- `/acp reset-options` clears all runtime overrides for target session.
## acpx harness support (current)
Current acpx built-in harness aliases:
@@ -333,14 +249,17 @@ See [Plugins](/tools/plugin).
## Troubleshooting
| Symptom | Likely cause | Fix |
| ----------------------------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------- |
| `ACP runtime backend is not configured` | Backend plugin missing or disabled. | Install and enable backend plugin, then run `/acp doctor`. |
| `ACP is disabled by policy (acp.enabled=false)` | ACP globally disabled. | Set `acp.enabled=true`. |
| `ACP dispatch is disabled by policy (acp.dispatch.enabled=false)` | Dispatch from normal thread messages disabled. | Set `acp.dispatch.enabled=true`. |
| `ACP agent "<id>" is not allowed by policy` | Agent not in allowlist. | Use allowed `agentId` or update `acp.allowedAgents`. |
| `Unable to resolve session target: ...` | Bad key/id/label token. | Run `/acp sessions`, copy exact key/label, retry. |
| `--thread here requires running /acp spawn inside an active ... thread` | `--thread here` used outside a thread context. | Move to target thread or use `--thread auto`/`off`. |
| `Only <user-id> can rebind this thread.` | Another user owns thread binding. | Rebind as owner or use a different thread. |
| `Thread bindings are unavailable for <channel>.` | Adapter lacks thread binding capability. | Use `--thread off` or move to supported adapter/channel. |
| Missing ACP metadata for bound session | Stale/deleted ACP session metadata. | Recreate with `/acp spawn`, then rebind/focus thread. |
- Error: `ACP runtime backend is not configured`
Install and enable the configured backend plugin, then run `/acp doctor`.
- Error: ACP dispatch disabled
Enable `acp.dispatch.enabled=true`.
- Error: target agent not allowed
Pass an allowed `agentId` or update `acp.allowedAgents`.
- Error: thread binding unavailable on this channel
Use a channel adapter that supports thread bindings, or run ACP in non-thread mode.
- Error: missing ACP metadata for a bound session
Recreate the session with `/acp spawn` (or `sessions_spawn` with `runtime:"acp"`) and rebind the thread.

View File

@@ -26,7 +26,7 @@ All skills-related configuration lives under `skills` in `~/.openclaw/openclaw.j
entries: {
"nano-banana-pro": {
enabled: true,
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string
apiKey: "GEMINI_KEY_HERE",
env: {
GEMINI_API_KEY: "GEMINI_KEY_HERE",
},
@@ -56,7 +56,6 @@ Per-skill fields:
- `enabled`: set `false` to disable a skill even if its bundled/installed.
- `env`: environment variables injected for the agent run (only if not already set).
- `apiKey`: optional convenience for skills that declare a primary env var.
Supports plaintext string or SecretRef object (`{ source, provider, id }`).
## Notes

View File

@@ -195,7 +195,7 @@ Bundled/managed skills can be toggled and supplied with env values:
entries: {
"nano-banana-pro": {
enabled: true,
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string
apiKey: "GEMINI_KEY_HERE",
env: {
GEMINI_API_KEY: "GEMINI_KEY_HERE",
},
@@ -221,7 +221,6 @@ Rules:
- `enabled: false` disables the skill even if its bundled/installed.
- `env`: injected **only if** the variable isnt already set in the process.
- `apiKey`: convenience for skills that declare `metadata.openclaw.primaryEnv`.
Supports plaintext string or SecretRef object (`{ source, provider, id }`).
- `config`: optional bag for custom per-skill fields; custom keys must live here.
- `allowBundled`: optional allowlist for **bundled** skills only. If set, only
bundled skills in the list are eligible (managed/workspace skills unaffected).

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/acpx",
"version": "2026.2.26",
"version": "2026.2.25",
"description": "OpenClaw ACP runtime backend via acpx",
"type": "module",
"dependencies": {

View File

@@ -294,7 +294,7 @@ describe("downloadBlueBubblesAttachment", () => {
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
});
it("auto-allowlists serverUrl hostname when allowPrivateNetwork is not set", async () => {
it("does not pass ssrfPolicy when allowPrivateNetwork is not set", async () => {
const mockBuffer = new Uint8Array([1]);
mockFetch.mockResolvedValueOnce({
ok: true,
@@ -309,25 +309,7 @@ describe("downloadBlueBubblesAttachment", () => {
});
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["localhost"] });
});
it("auto-allowlists private IP serverUrl hostname when allowPrivateNetwork is not set", async () => {
const mockBuffer = new Uint8Array([1]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-private-ip" };
await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://192.168.1.5:1234",
password: "test",
});
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["192.168.1.5"] });
expect(fetchMediaArgs.ssrfPolicy).toBeUndefined();
});
});

View File

@@ -62,15 +62,6 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
return resolveBlueBubblesServerAccount(params);
}
function safeExtractHostname(url: string): string | undefined {
try {
const hostname = new URL(url).hostname.trim();
return hostname || undefined;
} catch {
return undefined;
}
}
type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined {
@@ -98,17 +89,12 @@ export async function downloadBlueBubblesAttachment(
password,
});
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
const trustedHostname = safeExtractHostname(baseUrl);
try {
const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({
url,
filePathHint: attachment.transferName ?? attachment.guid ?? "attachment",
maxBytes,
ssrfPolicy: allowPrivateNetwork
? { allowPrivateNetwork: true }
: trustedHostname
? { allowedHostnames: [trustedHostname] }
: undefined,
ssrfPolicy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
fetchImpl: async (input, init) =>
await blueBubblesFetchWithTimeout(
resolveRequestUrl(input),

View File

@@ -1,12 +1,10 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
DM_GROUP_ACCESS_REASON,
createReplyPrefixOptions,
evictOldHistoryKeys,
logAckFailure,
logInboundDrop,
logTypingFailure,
readStoreAllowFromForDmPolicy,
recordPendingHistoryEntryIfEnabled,
resolveAckReaction,
resolveDmGroupAccessWithLists,
@@ -502,17 +500,14 @@ export async function processMessage(
const dmPolicy = account.config.dmPolicy ?? "pairing";
const groupPolicy = account.config.groupPolicy ?? "allowlist";
const configuredAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "bluebubbles",
dmPolicy,
readStore: (provider) => core.channel.pairing.readAllowFromStore(provider),
});
const storeAllowFrom = await core.channel.pairing
.readAllowFromStore("bluebubbles")
.catch(() => []);
const accessDecision = resolveDmGroupAccessWithLists({
isGroup,
dmPolicy,
groupPolicy,
allowFrom: configuredAllowFrom,
allowFrom: account.config.allowFrom,
groupAllowFrom: account.config.groupAllowFrom,
storeAllowFrom,
isSenderAllowed: (allowFrom) =>
@@ -535,7 +530,7 @@ export async function processMessage(
if (accessDecision.decision !== "allow") {
if (isGroup) {
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) {
if (accessDecision.reason === "groupPolicy=disabled") {
logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
logGroupAllowlistHint({
runtime,
@@ -546,7 +541,7 @@ export async function processMessage(
});
return;
}
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) {
if (accessDecision.reason === "groupPolicy=allowlist (empty allowlist)") {
logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
logGroupAllowlistHint({
runtime,
@@ -557,7 +552,7 @@ export async function processMessage(
});
return;
}
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) {
if (accessDecision.reason === "groupPolicy=allowlist (not allowlisted)") {
logVerbose(
core,
runtime,
@@ -580,7 +575,7 @@ export async function processMessage(
return;
}
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) {
if (accessDecision.reason === "dmPolicy=disabled") {
logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`);
logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`);
return;
@@ -667,11 +662,10 @@ export async function processMessage(
// Command gating (parity with iMessage/WhatsApp)
const useAccessGroups = config.commands?.useAccessGroups !== false;
const hasControlCmd = core.channel.text.hasControlCommand(messageText, config);
const commandDmAllowFrom = isGroup ? configuredAllowFrom : effectiveAllowFrom;
const ownerAllowedForCommands =
commandDmAllowFrom.length > 0
effectiveAllowFrom.length > 0
? isAllowedBlueBubblesSender({
allowFrom: commandDmAllowFrom,
allowFrom: effectiveAllowFrom,
sender: message.senderId,
chatId: message.chatId ?? undefined,
chatGuid: message.chatGuid ?? undefined,
@@ -692,7 +686,7 @@ export async function processMessage(
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
allowTextCommands: true,
@@ -1388,11 +1382,9 @@ export async function processReaction(
const dmPolicy = account.config.dmPolicy ?? "pairing";
const groupPolicy = account.config.groupPolicy ?? "allowlist";
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "bluebubbles",
dmPolicy,
readStore: (provider) => core.channel.pairing.readAllowFromStore(provider),
});
const storeAllowFrom = await core.channel.pairing
.readAllowFromStore("bluebubbles")
.catch(() => []);
const accessDecision = resolveDmGroupAccessWithLists({
isGroup: reaction.isGroup,
dmPolicy,

View File

@@ -162,24 +162,6 @@ function createMockRuntime(): PluginRuntime {
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
dispatchReplyFromConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
withReplyDispatcher: vi.fn(
async ({
dispatcher,
run,
onSettled,
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
try {
return await run();
} finally {
dispatcher.markComplete();
try {
await dispatcher.waitForIdle();
} finally {
await onSettled?.();
}
}
},
) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
finalizeInboundContext: vi.fn(
(ctx: Record<string, unknown>) => ctx,
) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],

View File

@@ -1,8 +1,7 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { createFeishuToolClient } from "./tool-account.js";
import { createFeishuClient } from "./client.js";
import type { FeishuConfig } from "./types.js";
// ============ Helpers ============
@@ -65,7 +64,10 @@ function parseBitableUrl(url: string): { token: string; tableId?: string; isWiki
}
/** Get app_token from wiki node_token */
async function getAppTokenFromWiki(client: Lark.Client, nodeToken: string): Promise<string> {
async function getAppTokenFromWiki(
client: ReturnType<typeof createFeishuClient>,
nodeToken: string,
): Promise<string> {
const res = await client.wiki.space.getNode({
params: { token: nodeToken },
});
@@ -85,7 +87,7 @@ async function getAppTokenFromWiki(client: Lark.Client, nodeToken: string): Prom
}
/** Get bitable metadata from URL (handles both /base/ and /wiki/ URLs) */
async function getBitableMeta(client: Lark.Client, url: string) {
async function getBitableMeta(client: ReturnType<typeof createFeishuClient>, url: string) {
const parsed = parseBitableUrl(url);
if (!parsed) {
throw new Error("Invalid URL format. Expected /base/XXX or /wiki/XXX URL");
@@ -132,7 +134,11 @@ async function getBitableMeta(client: Lark.Client, url: string) {
};
}
async function listFields(client: Lark.Client, appToken: string, tableId: string) {
async function listFields(
client: ReturnType<typeof createFeishuClient>,
appToken: string,
tableId: string,
) {
const res = await client.bitable.appTableField.list({
path: { app_token: appToken, table_id: tableId },
});
@@ -155,7 +161,7 @@ async function listFields(client: Lark.Client, appToken: string, tableId: string
}
async function listRecords(
client: Lark.Client,
client: ReturnType<typeof createFeishuClient>,
appToken: string,
tableId: string,
pageSize?: number,
@@ -180,7 +186,12 @@ async function listRecords(
};
}
async function getRecord(client: Lark.Client, appToken: string, tableId: string, recordId: string) {
async function getRecord(
client: ReturnType<typeof createFeishuClient>,
appToken: string,
tableId: string,
recordId: string,
) {
const res = await client.bitable.appTableRecord.get({
path: { app_token: appToken, table_id: tableId, record_id: recordId },
});
@@ -194,7 +205,7 @@ async function getRecord(client: Lark.Client, appToken: string, tableId: string,
}
async function createRecord(
client: Lark.Client,
client: ReturnType<typeof createFeishuClient>,
appToken: string,
tableId: string,
fields: Record<string, unknown>,
@@ -224,7 +235,7 @@ const DEFAULT_CLEANUP_FIELD_TYPES = new Set([3, 5, 17]); // SingleSelect, DateTi
/** Clean up default placeholder rows and fields in a newly created Bitable table */
async function cleanupNewBitable(
client: Lark.Client,
client: ReturnType<typeof createFeishuClient>,
appToken: string,
tableId: string,
tableName: string,
@@ -323,7 +334,7 @@ async function cleanupNewBitable(
}
async function createApp(
client: Lark.Client,
client: ReturnType<typeof createFeishuClient>,
name: string,
folderToken?: string,
logger?: CleanupLogger,
@@ -378,7 +389,7 @@ async function createApp(
}
async function createField(
client: Lark.Client,
client: ReturnType<typeof createFeishuClient>,
appToken: string,
tableId: string,
fieldName: string,
@@ -406,7 +417,7 @@ async function createField(
}
async function updateRecord(
client: Lark.Client,
client: ReturnType<typeof createFeishuClient>,
appToken: string,
tableId: string,
recordId: string,
@@ -521,193 +532,208 @@ const UpdateRecordSchema = Type.Object({
// ============ Tool Registration ============
export function registerFeishuBitableTools(api: OpenClawPluginApi) {
if (!api.config) {
api.logger.debug?.("feishu_bitable: No config available, skipping bitable tools");
const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg?.appId || !feishuCfg?.appSecret) {
api.logger.debug?.("feishu_bitable: Feishu credentials not configured, skipping bitable tools");
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_bitable: No Feishu accounts configured, skipping bitable tools");
return;
}
const getClient = () => createFeishuClient(feishuCfg);
type AccountAwareParams = { accountId?: string };
const getClient = (params: AccountAwareParams | undefined, defaultAccountId?: string) =>
createFeishuToolClient({ api, executeParams: params, defaultAccountId });
const registerBitableTool = <TParams extends AccountAwareParams>(params: {
name: string;
label: string;
description: string;
parameters: unknown;
execute: (args: { params: TParams; defaultAccountId?: string }) => Promise<unknown>;
}) => {
api.registerTool(
(ctx) => ({
name: params.name,
label: params.label,
description: params.description,
parameters: params.parameters,
async execute(_toolCallId, rawParams) {
try {
return json(
await params.execute({
params: rawParams as TParams,
defaultAccountId: ctx.agentAccountId,
}),
);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
}),
{ name: params.name },
);
};
registerBitableTool<{ url: string; accountId?: string }>({
name: "feishu_bitable_get_meta",
label: "Feishu Bitable Get Meta",
description:
"Parse a Bitable URL and get app_token, table_id, and table list. Use this first when given a /wiki/ or /base/ URL.",
parameters: GetMetaSchema,
async execute({ params, defaultAccountId }) {
return getBitableMeta(getClient(params, defaultAccountId), params.url);
// Tool 0: feishu_bitable_get_meta (helper to parse URLs)
api.registerTool(
{
name: "feishu_bitable_get_meta",
label: "Feishu Bitable Get Meta",
description:
"Parse a Bitable URL and get app_token, table_id, and table list. Use this first when given a /wiki/ or /base/ URL.",
parameters: GetMetaSchema,
async execute(_toolCallId, params) {
const { url } = params as { url: string };
try {
const result = await getBitableMeta(getClient(), url);
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
});
{ name: "feishu_bitable_get_meta" },
);
registerBitableTool<{ app_token: string; table_id: string; accountId?: string }>({
name: "feishu_bitable_list_fields",
label: "Feishu Bitable List Fields",
description: "List all fields (columns) in a Bitable table with their types and properties",
parameters: ListFieldsSchema,
async execute({ params, defaultAccountId }) {
return listFields(getClient(params, defaultAccountId), params.app_token, params.table_id);
// Tool 1: feishu_bitable_list_fields
api.registerTool(
{
name: "feishu_bitable_list_fields",
label: "Feishu Bitable List Fields",
description: "List all fields (columns) in a Bitable table with their types and properties",
parameters: ListFieldsSchema,
async execute(_toolCallId, params) {
const { app_token, table_id } = params as { app_token: string; table_id: string };
try {
const result = await listFields(getClient(), app_token, table_id);
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
});
{ name: "feishu_bitable_list_fields" },
);
registerBitableTool<{
app_token: string;
table_id: string;
page_size?: number;
page_token?: string;
accountId?: string;
}>({
name: "feishu_bitable_list_records",
label: "Feishu Bitable List Records",
description: "List records (rows) from a Bitable table with pagination support",
parameters: ListRecordsSchema,
async execute({ params, defaultAccountId }) {
return listRecords(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.page_size,
params.page_token,
);
// Tool 2: feishu_bitable_list_records
api.registerTool(
{
name: "feishu_bitable_list_records",
label: "Feishu Bitable List Records",
description: "List records (rows) from a Bitable table with pagination support",
parameters: ListRecordsSchema,
async execute(_toolCallId, params) {
const { app_token, table_id, page_size, page_token } = params as {
app_token: string;
table_id: string;
page_size?: number;
page_token?: string;
};
try {
const result = await listRecords(getClient(), app_token, table_id, page_size, page_token);
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
});
{ name: "feishu_bitable_list_records" },
);
registerBitableTool<{
app_token: string;
table_id: string;
record_id: string;
accountId?: string;
}>({
name: "feishu_bitable_get_record",
label: "Feishu Bitable Get Record",
description: "Get a single record by ID from a Bitable table",
parameters: GetRecordSchema,
async execute({ params, defaultAccountId }) {
return getRecord(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.record_id,
);
// Tool 3: feishu_bitable_get_record
api.registerTool(
{
name: "feishu_bitable_get_record",
label: "Feishu Bitable Get Record",
description: "Get a single record by ID from a Bitable table",
parameters: GetRecordSchema,
async execute(_toolCallId, params) {
const { app_token, table_id, record_id } = params as {
app_token: string;
table_id: string;
record_id: string;
};
try {
const result = await getRecord(getClient(), app_token, table_id, record_id);
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
});
{ name: "feishu_bitable_get_record" },
);
registerBitableTool<{
app_token: string;
table_id: string;
fields: Record<string, unknown>;
accountId?: string;
}>({
name: "feishu_bitable_create_record",
label: "Feishu Bitable Create Record",
description: "Create a new record (row) in a Bitable table",
parameters: CreateRecordSchema,
async execute({ params, defaultAccountId }) {
return createRecord(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.fields,
);
// Tool 4: feishu_bitable_create_record
api.registerTool(
{
name: "feishu_bitable_create_record",
label: "Feishu Bitable Create Record",
description: "Create a new record (row) in a Bitable table",
parameters: CreateRecordSchema,
async execute(_toolCallId, params) {
const { app_token, table_id, fields } = params as {
app_token: string;
table_id: string;
fields: Record<string, unknown>;
};
try {
const result = await createRecord(getClient(), app_token, table_id, fields);
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
});
{ name: "feishu_bitable_create_record" },
);
registerBitableTool<{
app_token: string;
table_id: string;
record_id: string;
fields: Record<string, unknown>;
accountId?: string;
}>({
name: "feishu_bitable_update_record",
label: "Feishu Bitable Update Record",
description: "Update an existing record (row) in a Bitable table",
parameters: UpdateRecordSchema,
async execute({ params, defaultAccountId }) {
return updateRecord(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.record_id,
params.fields,
);
// Tool 5: feishu_bitable_update_record
api.registerTool(
{
name: "feishu_bitable_update_record",
label: "Feishu Bitable Update Record",
description: "Update an existing record (row) in a Bitable table",
parameters: UpdateRecordSchema,
async execute(_toolCallId, params) {
const { app_token, table_id, record_id, fields } = params as {
app_token: string;
table_id: string;
record_id: string;
fields: Record<string, unknown>;
};
try {
const result = await updateRecord(getClient(), app_token, table_id, record_id, fields);
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
});
{ name: "feishu_bitable_update_record" },
);
registerBitableTool<{ name: string; folder_token?: string; accountId?: string }>({
name: "feishu_bitable_create_app",
label: "Feishu Bitable Create App",
description: "Create a new Bitable (multidimensional table) application",
parameters: CreateAppSchema,
async execute({ params, defaultAccountId }) {
return createApp(getClient(params, defaultAccountId), params.name, params.folder_token, {
debug: (msg) => api.logger.debug?.(msg),
warn: (msg) => api.logger.warn?.(msg),
});
// Tool 6: feishu_bitable_create_app
api.registerTool(
{
name: "feishu_bitable_create_app",
label: "Feishu Bitable Create App",
description: "Create a new Bitable (multidimensional table) application",
parameters: CreateAppSchema,
async execute(_toolCallId, params) {
const { name, folder_token } = params as { name: string; folder_token?: string };
try {
const result = await createApp(getClient(), name, folder_token, {
debug: (msg) => api.logger.debug?.(msg),
warn: (msg) => api.logger.warn?.(msg),
});
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
});
{ name: "feishu_bitable_create_app" },
);
registerBitableTool<{
app_token: string;
table_id: string;
field_name: string;
field_type: number;
property?: Record<string, unknown>;
accountId?: string;
}>({
name: "feishu_bitable_create_field",
label: "Feishu Bitable Create Field",
description: "Create a new field (column) in a Bitable table",
parameters: CreateFieldSchema,
async execute({ params, defaultAccountId }) {
return createField(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.field_name,
params.field_type,
params.property,
);
// Tool 7: feishu_bitable_create_field
api.registerTool(
{
name: "feishu_bitable_create_field",
label: "Feishu Bitable Create Field",
description: "Create a new field (column) in a Bitable table",
parameters: CreateFieldSchema,
async execute(_toolCallId, params) {
const { app_token, table_id, field_name, field_type, property } = params as {
app_token: string;
table_id: string;
field_name: string;
field_type: number;
property?: Record<string, unknown>;
};
try {
const result = await createField(
getClient(),
app_token,
table_id,
field_name,
field_type,
property,
);
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
});
{ name: "feishu_bitable_create_field" },
);
api.logger.info?.("feishu_bitable: Registered bitable tools");
}

View File

@@ -1,7 +1,7 @@
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FeishuMessageEvent } from "./bot.js";
import { buildFeishuAgentBody, handleFeishuMessage } from "./bot.js";
import { handleFeishuMessage } from "./bot.js";
import { setFeishuRuntime } from "./runtime.js";
const {
@@ -9,7 +9,6 @@ const {
mockSendMessageFeishu,
mockGetMessageFeishu,
mockDownloadMessageResourceFeishu,
mockCreateFeishuClient,
} = vi.hoisted(() => ({
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
dispatcher: vi.fn(),
@@ -23,7 +22,6 @@ const {
contentType: "video/mp4",
fileName: "clip.mp4",
}),
mockCreateFeishuClient: vi.fn(),
}));
vi.mock("./reply-dispatcher.js", () => ({
@@ -39,10 +37,6 @@ vi.mock("./media.js", () => ({
downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
}));
vi.mock("./client.js", () => ({
createFeishuClient: mockCreateFeishuClient,
}));
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
@@ -61,53 +55,11 @@ async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessa
});
}
describe("buildFeishuAgentBody", () => {
it("builds message id, speaker, quoted content, mentions, and permission notice in order", () => {
const body = buildFeishuAgentBody({
ctx: {
content: "hello world",
senderName: "Sender Name",
senderOpenId: "ou-sender",
messageId: "msg-42",
mentionTargets: [{ openId: "ou-target", name: "Target User", key: "@_user_1" }],
},
quotedContent: "previous message",
permissionErrorForAgent: {
code: 99991672,
message: "permission denied",
grantUrl: "https://open.feishu.cn/app/cli_test",
},
});
expect(body).toBe(
'[message_id: msg-42]\nSender Name: [Replying to: "previous message"]\n\nhello world\n\n[System: Your reply will automatically @mention: Target User. Do not write @xxx yourself.]\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: https://open.feishu.cn/app/cli_test]',
);
});
});
describe("handleFeishuMessage command authorization", () => {
const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
const mockDispatchReplyFromConfig = vi
.fn()
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
const mockWithReplyDispatcher = vi.fn(
async ({
dispatcher,
run,
onSettled,
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
try {
return await run();
} finally {
dispatcher.markComplete();
try {
await dispatcher.waitForIdle();
} finally {
await onSettled?.();
}
}
},
);
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
const mockShouldComputeCommandAuthorized = vi.fn(() => true);
const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
@@ -120,13 +72,6 @@ describe("handleFeishuMessage command authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateFeishuClient.mockReturnValue({
contact: {
user: {
get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
},
},
});
setFeishuRuntime({
system: {
enqueueSystemEvent: vi.fn(),
@@ -145,7 +90,6 @@ describe("handleFeishuMessage command authorization", () => {
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
finalizeInboundContext: mockFinalizeInboundContext,
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
withReplyDispatcher: mockWithReplyDispatcher,
},
commands: {
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
@@ -438,102 +382,4 @@ describe("handleFeishuMessage command authorization", () => {
"clip.mp4",
);
});
it("includes message_id in BodyForAgent on its own line", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-msgid",
},
},
message: {
message_id: "msg-message-id-line",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
BodyForAgent: "[message_id: msg-message-id-line]\nou-msgid: hello",
}),
);
});
it("dispatches once and appends permission notice to the main agent body", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockCreateFeishuClient.mockReturnValue({
contact: {
user: {
get: vi.fn().mockRejectedValue({
response: {
data: {
code: 99991672,
msg: "permission denied https://open.feishu.cn/app/cli_test",
},
},
}),
},
},
});
const cfg: ClawdbotConfig = {
channels: {
feishu: {
appId: "cli_test",
appSecret: "sec_test",
groups: {
"oc-group": {
requireMention: false,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-perm",
},
},
message: {
message_id: "msg-perm-1",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello group" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
BodyForAgent: expect.stringContaining(
"Permission grant URL: https://open.feishu.cn/app/cli_test",
),
}),
);
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
BodyForAgent: expect.stringContaining("ou-perm: hello group"),
}),
);
});
});

View File

@@ -496,40 +496,6 @@ export function parseFeishuMessageEvent(
return ctx;
}
export function buildFeishuAgentBody(params: {
ctx: Pick<
FeishuMessageContext,
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId"
>;
quotedContent?: string;
permissionErrorForAgent?: PermissionError;
}): string {
const { ctx, quotedContent, permissionErrorForAgent } = params;
let messageBody = ctx.content;
if (quotedContent) {
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
}
// DMs already have per-sender sessions, but this label still improves attribution.
const speaker = ctx.senderName ?? ctx.senderOpenId;
messageBody = `${speaker}: ${messageBody}`;
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
}
// Keep message_id on its own line so shared message-id hint stripping can parse it reliably.
messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`;
if (permissionErrorForAgent) {
const grantUrl = permissionErrorForAgent.grantUrl ?? "";
messageBody += `\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
}
return messageBody;
}
export async function handleFeishuMessage(params: {
cfg: ClawdbotConfig;
event: FeishuMessageEvent;
@@ -857,15 +823,85 @@ export async function handleFeishuMessage(params: {
}
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const messageBody = buildFeishuAgentBody({
ctx,
quotedContent,
permissionErrorForAgent,
});
// Build message body with quoted content if available
let messageBody = ctx.content;
if (quotedContent) {
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
}
// Include a readable speaker label so the model can attribute instructions.
// (DMs already have per-sender sessions, but the prefix is still useful for clarity.)
const speaker = ctx.senderName ?? ctx.senderOpenId;
messageBody = `${speaker}: ${messageBody}`;
// If there are mention targets, inform the agent that replies will auto-mention them
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
}
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
// If there's a permission error, dispatch a separate notification first
if (permissionErrorForAgent) {
// Keep the notice in a single dispatch to avoid duplicate replies (#27372).
log(`feishu[${account.accountId}]: appending permission error notice to message body`);
const grantUrl = permissionErrorForAgent.grantUrl ?? "";
const permissionNotifyBody = `[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
const permissionBody = core.channel.reply.formatAgentEnvelope({
channel: "Feishu",
from: envelopeFrom,
timestamp: new Date(),
envelope: envelopeOptions,
body: permissionNotifyBody,
});
const permissionCtx = core.channel.reply.finalizeInboundContext({
Body: permissionBody,
BodyForAgent: permissionNotifyBody,
RawBody: permissionNotifyBody,
CommandBody: permissionNotifyBody,
From: feishuFrom,
To: feishuTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
GroupSubject: isGroup ? ctx.chatId : undefined,
SenderName: "system",
SenderId: "system",
Provider: "feishu" as const,
Surface: "feishu" as const,
MessageSid: `${ctx.messageId}:permission-error`,
Timestamp: Date.now(),
WasMentioned: false,
CommandAuthorized: commandAuthorized,
OriginatingChannel: "feishu" as const,
OriginatingTo: feishuTo,
});
const {
dispatcher: permDispatcher,
replyOptions: permReplyOptions,
markDispatchIdle: markPermIdle,
} = createFeishuReplyDispatcher({
cfg,
agentId: route.agentId,
runtime: runtime as RuntimeEnv,
chatId: ctx.chatId,
replyToMessageId: ctx.messageId,
accountId: account.accountId,
});
log(`feishu[${account.accountId}]: dispatching permission error notification to agent`);
await core.channel.reply.dispatchReplyFromConfig({
ctx: permissionCtx,
cfg,
dispatcher: permDispatcher,
replyOptions: permReplyOptions,
});
markPermIdle();
}
const body = core.channel.reply.formatAgentEnvelope({
@@ -908,7 +944,7 @@ export async function handleFeishuMessage(params: {
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
BodyForAgent: messageBody,
BodyForAgent: ctx.content,
InboundHistory: inboundHistory,
RawBody: ctx.content,
CommandBody: ctx.content,
@@ -943,20 +979,16 @@ export async function handleFeishuMessage(params: {
});
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions,
}),
replyOptions,
});
markDispatchIdle();
if (isGroup && historyKey && chatHistories) {
clearHistoryEntriesIfEnabled({
historyMap: chatHistories,

View File

@@ -1,76 +0,0 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { describe, expect, test, vi } from "vitest";
import { registerFeishuDocTools } from "./docx.js";
import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
const createFeishuClientMock = vi.fn((creds: { appId?: string } | undefined) => ({
__appId: creds?.appId,
}));
vi.mock("./client.js", () => {
return {
createFeishuClient: (creds: { appId?: string } | undefined) => createFeishuClientMock(creds),
};
});
// Patch SDK import so tool execution can run without network concerns.
vi.mock("@larksuiteoapi/node-sdk", () => {
return {
default: {},
};
});
describe("feishu_doc account selection", () => {
test("uses agentAccountId context when params omit accountId", async () => {
const cfg = {
channels: {
feishu: {
enabled: true,
accounts: {
a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } },
b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } },
},
},
},
} as OpenClawPluginApi["config"];
const { api, resolveTool } = createToolFactoryHarness(cfg);
registerFeishuDocTools(api);
const docToolA = resolveTool("feishu_doc", { agentAccountId: "a" });
const docToolB = resolveTool("feishu_doc", { agentAccountId: "b" });
await docToolA.execute("call-a", { action: "list_blocks", doc_token: "d" });
await docToolB.execute("call-b", { action: "list_blocks", doc_token: "d" });
expect(createFeishuClientMock).toHaveBeenCalledTimes(2);
expect(createFeishuClientMock.mock.calls[0]?.[0]?.appId).toBe("app-a");
expect(createFeishuClientMock.mock.calls[1]?.[0]?.appId).toBe("app-b");
});
test("explicit accountId param overrides agentAccountId context", async () => {
const cfg = {
channels: {
feishu: {
enabled: true,
accounts: {
a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } },
b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } },
},
},
},
} as OpenClawPluginApi["config"];
const { api, resolveTool } = createToolFactoryHarness(cfg);
registerFeishuDocTools(api);
const docTool = resolveTool("feishu_doc", { agentAccountId: "b" });
await docTool.execute("call-override", {
action: "list_blocks",
doc_token: "d",
accountId: "a",
});
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-a");
});
});

View File

@@ -104,7 +104,6 @@ describe("feishu_doc image fetch hardening", () => {
const feishuDocTool = registerTool.mock.calls
.map((call) => call[0])
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
.find((tool) => tool.name === "feishu_doc");
expect(feishuDocTool).toBeDefined();

View File

@@ -3,13 +3,10 @@ import type * as Lark from "@larksuiteoapi/node-sdk";
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js";
import { getFeishuRuntime } from "./runtime.js";
import {
createFeishuToolClient,
resolveAnyEnabledFeishuToolsConfig,
resolveFeishuToolAccount,
} from "./tool-account.js";
import { resolveToolsConfig } from "./tools-config.js";
// ============ Helpers ============
@@ -457,80 +454,53 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
return;
}
// Register if enabled on any account; account routing is resolved per execution.
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
// Use first account's config for tools configuration
const firstAccount = accounts[0];
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
const mediaMaxBytes = (firstAccount.config?.mediaMaxMb ?? 30) * 1024 * 1024;
// Helper to get client for the default account
const getClient = () => createFeishuClient(firstAccount);
const registered: string[] = [];
type FeishuDocExecuteParams = FeishuDocParams & { accountId?: string };
const getClient = (params: { accountId?: string } | undefined, defaultAccountId?: string) =>
createFeishuToolClient({ api, executeParams: params, defaultAccountId });
const getMediaMaxBytes = (
params: { accountId?: string } | undefined,
defaultAccountId?: string,
) =>
(resolveFeishuToolAccount({ api, executeParams: params, defaultAccountId }).config
?.mediaMaxMb ?? 30) *
1024 *
1024;
// Main document tool with action-based dispatch
if (toolsCfg.doc) {
api.registerTool(
(ctx) => {
const defaultAccountId = ctx.agentAccountId;
return {
name: "feishu_doc",
label: "Feishu Doc",
description:
"Feishu document operations. Actions: read, write, append, create, list_blocks, get_block, update_block, delete_block",
parameters: FeishuDocSchema,
async execute(_toolCallId, params) {
const p = params as FeishuDocExecuteParams;
try {
const client = getClient(p, defaultAccountId);
switch (p.action) {
case "read":
return json(await readDoc(client, p.doc_token));
case "write":
return json(
await writeDoc(
client,
p.doc_token,
p.content,
getMediaMaxBytes(p, defaultAccountId),
),
);
case "append":
return json(
await appendDoc(
client,
p.doc_token,
p.content,
getMediaMaxBytes(p, defaultAccountId),
),
);
case "create":
return json(await createDoc(client, p.title, p.folder_token));
case "list_blocks":
return json(await listBlocks(client, p.doc_token));
case "get_block":
return json(await getBlock(client, p.doc_token, p.block_id));
case "update_block":
return json(await updateBlock(client, p.doc_token, p.block_id, p.content));
case "delete_block":
return json(await deleteBlock(client, p.doc_token, p.block_id));
default: {
const exhaustiveCheck: never = p;
return json({ error: `Unknown action: ${String(exhaustiveCheck)}` });
}
}
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
{
name: "feishu_doc",
label: "Feishu Doc",
description:
"Feishu document operations. Actions: read, write, append, create, list_blocks, get_block, update_block, delete_block",
parameters: FeishuDocSchema,
async execute(_toolCallId, params) {
const p = params as FeishuDocParams;
try {
const client = getClient();
switch (p.action) {
case "read":
return json(await readDoc(client, p.doc_token));
case "write":
return json(await writeDoc(client, p.doc_token, p.content, mediaMaxBytes));
case "append":
return json(await appendDoc(client, p.doc_token, p.content, mediaMaxBytes));
case "create":
return json(await createDoc(client, p.title, p.folder_token));
case "list_blocks":
return json(await listBlocks(client, p.doc_token));
case "get_block":
return json(await getBlock(client, p.doc_token, p.block_id));
case "update_block":
return json(await updateBlock(client, p.doc_token, p.block_id, p.content));
case "delete_block":
return json(await deleteBlock(client, p.doc_token, p.block_id));
default:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
return json({ error: `Unknown action: ${(p as any).action}` });
}
},
};
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
{ name: "feishu_doc" },
);
@@ -540,7 +510,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
// Keep feishu_app_scopes as independent tool
if (toolsCfg.scopes) {
api.registerTool(
(ctx) => ({
{
name: "feishu_app_scopes",
label: "Feishu App Scopes",
description:
@@ -548,13 +518,13 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
parameters: Type.Object({}),
async execute() {
try {
const result = await listAppScopes(getClient(undefined, ctx.agentAccountId));
const result = await listAppScopes(getClient());
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
}),
},
{ name: "feishu_app_scopes" },
);
registered.push("feishu_app_scopes");

View File

@@ -1,8 +1,9 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
import { resolveToolsConfig } from "./tools-config.js";
// ============ Helpers ============
@@ -179,51 +180,45 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
return;
}
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
const firstAccount = accounts[0];
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
if (!toolsCfg.drive) {
api.logger.debug?.("feishu_drive: drive tool disabled in config");
return;
}
type FeishuDriveExecuteParams = FeishuDriveParams & { accountId?: string };
const getClient = () => createFeishuClient(firstAccount);
api.registerTool(
(ctx) => {
const defaultAccountId = ctx.agentAccountId;
return {
name: "feishu_drive",
label: "Feishu Drive",
description:
"Feishu cloud storage operations. Actions: list, info, create_folder, move, delete",
parameters: FeishuDriveSchema,
async execute(_toolCallId, params) {
const p = params as FeishuDriveExecuteParams;
try {
const client = createFeishuToolClient({
api,
executeParams: p,
defaultAccountId,
});
switch (p.action) {
case "list":
return json(await listFolder(client, p.folder_token));
case "info":
return json(await getFileInfo(client, p.file_token));
case "create_folder":
return json(await createFolder(client, p.name, p.folder_token));
case "move":
return json(await moveFile(client, p.file_token, p.type, p.folder_token));
case "delete":
return json(await deleteFile(client, p.file_token, p.type));
default:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
return json({ error: `Unknown action: ${(p as any).action}` });
}
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
{
name: "feishu_drive",
label: "Feishu Drive",
description:
"Feishu cloud storage operations. Actions: list, info, create_folder, move, delete",
parameters: FeishuDriveSchema,
async execute(_toolCallId, params) {
const p = params as FeishuDriveParams;
try {
const client = getClient();
switch (p.action) {
case "list":
return json(await listFolder(client, p.folder_token));
case "info":
return json(await getFileInfo(client, p.file_token));
case "create_folder":
return json(await createFolder(client, p.name, p.folder_token));
case "move":
return json(await moveFile(client, p.file_token, p.type, p.folder_token));
case "delete":
return json(await deleteFile(client, p.file_token, p.type));
default:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
return json({ error: `Unknown action: ${(p as any).action}` });
}
},
};
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
{ name: "feishu_drive" },
);

View File

@@ -1,8 +1,9 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js";
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
import { resolveToolsConfig } from "./tools-config.js";
// ============ Helpers ============
@@ -128,50 +129,42 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
return;
}
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
const firstAccount = accounts[0];
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
if (!toolsCfg.perm) {
api.logger.debug?.("feishu_perm: perm tool disabled in config (default: false)");
return;
}
type FeishuPermExecuteParams = FeishuPermParams & { accountId?: string };
const getClient = () => createFeishuClient(firstAccount);
api.registerTool(
(ctx) => {
const defaultAccountId = ctx.agentAccountId;
return {
name: "feishu_perm",
label: "Feishu Perm",
description: "Feishu permission management. Actions: list, add, remove",
parameters: FeishuPermSchema,
async execute(_toolCallId, params) {
const p = params as FeishuPermExecuteParams;
try {
const client = createFeishuToolClient({
api,
executeParams: p,
defaultAccountId,
});
switch (p.action) {
case "list":
return json(await listMembers(client, p.token, p.type));
case "add":
return json(
await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm),
);
case "remove":
return json(
await removeMember(client, p.token, p.type, p.member_type, p.member_id),
);
default:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
return json({ error: `Unknown action: ${(p as any).action}` });
}
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
{
name: "feishu_perm",
label: "Feishu Perm",
description: "Feishu permission management. Actions: list, add, remove",
parameters: FeishuPermSchema,
async execute(_toolCallId, params) {
const p = params as FeishuPermParams;
try {
const client = getClient();
switch (p.action) {
case "list":
return json(await listMembers(client, p.token, p.type));
case "add":
return json(
await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm),
);
case "remove":
return json(await removeMember(client, p.token, p.type, p.member_type, p.member_id));
default:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
return json({ error: `Unknown action: ${(p as any).action}` });
}
},
};
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
{ name: "feishu_perm" },
);

View File

@@ -1,111 +0,0 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { registerFeishuBitableTools } from "./bitable.js";
import { registerFeishuDriveTools } from "./drive.js";
import { registerFeishuPermTools } from "./perm.js";
import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
import { registerFeishuWikiTools } from "./wiki.js";
const createFeishuClientMock = vi.fn((account: { appId?: string } | undefined) => ({
__appId: account?.appId,
}));
vi.mock("./client.js", () => ({
createFeishuClient: (account: { appId?: string } | undefined) => createFeishuClientMock(account),
}));
function createConfig(params: {
toolsA?: {
wiki?: boolean;
drive?: boolean;
perm?: boolean;
};
toolsB?: {
wiki?: boolean;
drive?: boolean;
perm?: boolean;
};
}): OpenClawPluginApi["config"] {
return {
channels: {
feishu: {
enabled: true,
accounts: {
a: {
appId: "app-a",
appSecret: "sec-a",
tools: params.toolsA,
},
b: {
appId: "app-b",
appSecret: "sec-b",
tools: params.toolsB,
},
},
},
},
} as OpenClawPluginApi["config"];
}
describe("feishu tool account routing", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("wiki tool registers when first account disables it and routes to agentAccountId", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
toolsA: { wiki: false },
toolsB: { wiki: true },
}),
);
registerFeishuWikiTools(api);
const tool = resolveTool("feishu_wiki", { agentAccountId: "b" });
await tool.execute("call", { action: "search" });
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
});
test("drive tool registers when first account disables it and routes to agentAccountId", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
toolsA: { drive: false },
toolsB: { drive: true },
}),
);
registerFeishuDriveTools(api);
const tool = resolveTool("feishu_drive", { agentAccountId: "b" });
await tool.execute("call", { action: "unknown_action" });
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
});
test("perm tool registers when only second account enables it and routes to agentAccountId", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
toolsA: { perm: false },
toolsB: { perm: true },
}),
);
registerFeishuPermTools(api);
const tool = resolveTool("feishu_perm", { agentAccountId: "b" });
await tool.execute("call", { action: "unknown_action" });
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
});
test("bitable tool routes to agentAccountId and allows explicit accountId override", async () => {
const { api, resolveTool } = createToolFactoryHarness(createConfig({}));
registerFeishuBitableTools(api);
const tool = resolveTool("feishu_bitable_get_meta", { agentAccountId: "b" });
await tool.execute("call-ctx", { url: "invalid-url" });
await tool.execute("call-override", { url: "invalid-url", accountId: "a" });
expect(createFeishuClientMock.mock.calls[0]?.[0]?.appId).toBe("app-b");
expect(createFeishuClientMock.mock.calls[1]?.[0]?.appId).toBe("app-a");
});
});

View File

@@ -1,58 +0,0 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { resolveToolsConfig } from "./tools-config.js";
import type { FeishuToolsConfig, ResolvedFeishuAccount } from "./types.js";
type AccountAwareParams = { accountId?: string };
function normalizeOptionalAccountId(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function resolveFeishuToolAccount(params: {
api: Pick<OpenClawPluginApi, "config">;
executeParams?: AccountAwareParams;
defaultAccountId?: string;
}): ResolvedFeishuAccount {
if (!params.api.config) {
throw new Error("Feishu config unavailable");
}
return resolveFeishuAccount({
cfg: params.api.config,
accountId:
normalizeOptionalAccountId(params.executeParams?.accountId) ??
normalizeOptionalAccountId(params.defaultAccountId),
});
}
export function createFeishuToolClient(params: {
api: Pick<OpenClawPluginApi, "config">;
executeParams?: AccountAwareParams;
defaultAccountId?: string;
}): Lark.Client {
return createFeishuClient(resolveFeishuToolAccount(params));
}
export function resolveAnyEnabledFeishuToolsConfig(
accounts: ResolvedFeishuAccount[],
): Required<FeishuToolsConfig> {
const merged: Required<FeishuToolsConfig> = {
doc: false,
wiki: false,
drive: false,
perm: false,
scopes: false,
};
for (const account of accounts) {
const cfg = resolveToolsConfig(account.config.tools);
merged.doc = merged.doc || cfg.doc;
merged.wiki = merged.wiki || cfg.wiki;
merged.drive = merged.drive || cfg.drive;
merged.perm = merged.perm || cfg.perm;
merged.scopes = merged.scopes || cfg.scopes;
}
return merged;
}

View File

@@ -1,76 +0,0 @@
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
type ToolContextLike = {
agentAccountId?: string;
};
type ToolFactoryLike = (ctx: ToolContextLike) => AnyAgentTool | AnyAgentTool[] | null | undefined;
export type ToolLike = {
name: string;
execute: (toolCallId: string, params: unknown) => Promise<unknown> | unknown;
};
type RegisteredTool = {
tool: AnyAgentTool | ToolFactoryLike;
opts?: { name?: string };
};
function toToolList(value: AnyAgentTool | AnyAgentTool[] | null | undefined): AnyAgentTool[] {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
function asToolLike(tool: AnyAgentTool, fallbackName?: string): ToolLike {
const candidate = tool as Partial<ToolLike>;
const name = candidate.name ?? fallbackName;
const execute = candidate.execute;
if (!name || typeof execute !== "function") {
throw new Error(`Resolved tool is missing required fields (name=${String(name)})`);
}
return {
name,
execute: (toolCallId, params) => execute(toolCallId, params),
};
}
export function createToolFactoryHarness(cfg: OpenClawPluginApi["config"]) {
const registered: RegisteredTool[] = [];
const api: Pick<OpenClawPluginApi, "config" | "logger" | "registerTool"> = {
config: cfg,
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
registerTool: (tool, opts) => {
registered.push({ tool, opts });
},
};
const resolveTool = (name: string, ctx: ToolContextLike = {}): ToolLike => {
for (const entry of registered) {
if (entry.opts?.name === name && typeof entry.tool !== "function") {
return asToolLike(entry.tool, name);
}
if (typeof entry.tool === "function") {
const builtTools = toToolList(entry.tool(ctx));
const hit = builtTools.find((tool) => (tool as { name?: string }).name === name);
if (hit) {
return asToolLike(hit, name);
}
} else if ((entry.tool as { name?: string }).name === name) {
return asToolLike(entry.tool, name);
}
}
throw new Error(`Tool not registered: ${name}`);
};
return {
api: api as OpenClawPluginApi,
resolveTool,
};
}

View File

@@ -1,7 +1,8 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
import { createFeishuClient } from "./client.js";
import { resolveToolsConfig } from "./tools-config.js";
import { FeishuWikiSchema, type FeishuWikiParams } from "./wiki-schema.js";
// ============ Helpers ============
@@ -167,68 +168,62 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
return;
}
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
const firstAccount = accounts[0];
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
if (!toolsCfg.wiki) {
api.logger.debug?.("feishu_wiki: wiki tool disabled in config");
return;
}
type FeishuWikiExecuteParams = FeishuWikiParams & { accountId?: string };
const getClient = () => createFeishuClient(firstAccount);
api.registerTool(
(ctx) => {
const defaultAccountId = ctx.agentAccountId;
return {
name: "feishu_wiki",
label: "Feishu Wiki",
description:
"Feishu knowledge base operations. Actions: spaces, nodes, get, create, move, rename",
parameters: FeishuWikiSchema,
async execute(_toolCallId, params) {
const p = params as FeishuWikiExecuteParams;
try {
const client = createFeishuToolClient({
api,
executeParams: p,
defaultAccountId,
});
switch (p.action) {
case "spaces":
return json(await listSpaces(client));
case "nodes":
return json(await listNodes(client, p.space_id, p.parent_node_token));
case "get":
return json(await getNode(client, p.token));
case "search":
return json({
error:
"Search is not available. Use feishu_wiki with action: 'nodes' to browse or action: 'get' to lookup by token.",
});
case "create":
return json(
await createNode(client, p.space_id, p.title, p.obj_type, p.parent_node_token),
);
case "move":
return json(
await moveNode(
client,
p.space_id,
p.node_token,
p.target_space_id,
p.target_parent_token,
),
);
case "rename":
return json(await renameNode(client, p.space_id, p.node_token, p.title));
default:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
return json({ error: `Unknown action: ${(p as any).action}` });
}
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
{
name: "feishu_wiki",
label: "Feishu Wiki",
description:
"Feishu knowledge base operations. Actions: spaces, nodes, get, create, move, rename",
parameters: FeishuWikiSchema,
async execute(_toolCallId, params) {
const p = params as FeishuWikiParams;
try {
const client = getClient();
switch (p.action) {
case "spaces":
return json(await listSpaces(client));
case "nodes":
return json(await listNodes(client, p.space_id, p.parent_node_token));
case "get":
return json(await getNode(client, p.token));
case "search":
return json({
error:
"Search is not available. Use feishu_wiki with action: 'nodes' to browse or action: 'get' to lookup by token.",
});
case "create":
return json(
await createNode(client, p.space_id, p.title, p.obj_type, p.parent_node_token),
);
case "move":
return json(
await moveNode(
client,
p.space_id,
p.node_token,
p.target_space_id,
p.target_parent_token,
),
);
case "rename":
return json(await renameNode(client, p.space_id, p.node_token, p.title));
default:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
return json({ error: `Unknown action: ${(p as any).action}` });
}
},
};
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
{ name: "feishu_wiki" },
);

View File

@@ -96,41 +96,6 @@ describe("extractGeminiCliCredentials", () => {
return layout;
}
function installNpmShimLayout(params: { oauth2Exists?: boolean; oauth2Content?: string }) {
const binDir = join(rootDir, "fake", "npm-bin");
const geminiPath = join(binDir, "gemini");
const resolvedPath = geminiPath;
const oauth2Path = join(
binDir,
"node_modules",
"@google",
"gemini-cli",
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"src",
"code_assist",
"oauth2.js",
);
process.env.PATH = binDir;
mockExistsSync.mockImplementation((p: string) => {
const normalized = normalizePath(p);
if (normalized === normalizePath(geminiPath)) {
return true;
}
if (params.oauth2Exists && normalized === normalizePath(oauth2Path)) {
return true;
}
return false;
});
mockRealpathSync.mockReturnValue(resolvedPath);
if (params.oauth2Content !== undefined) {
mockReadFileSync.mockReturnValue(params.oauth2Content);
}
}
beforeEach(async () => {
vi.clearAllMocks();
originalPath = process.env.PATH;
@@ -162,19 +127,6 @@ describe("extractGeminiCliCredentials", () => {
});
});
it("extracts credentials when PATH entry is an npm global shim", async () => {
installNpmShimLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT });
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
clearCredentialsCache();
const result = extractGeminiCliCredentials();
expect(result).toEqual({
clientId: FAKE_CLIENT_ID,
clientSecret: FAKE_CLIENT_SECRET,
});
});
it("returns null when oauth2.js cannot be found", async () => {
installGeminiLayout({ oauth2Exists: false, readdir: [] });

View File

@@ -71,45 +71,41 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret:
}
const resolvedPath = realpathSync(geminiPath);
const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath);
const geminiCliDir = dirname(dirname(resolvedPath));
const searchPaths = [
join(
geminiCliDir,
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"src",
"code_assist",
"oauth2.js",
),
join(
geminiCliDir,
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"code_assist",
"oauth2.js",
),
];
let content: string | null = null;
for (const geminiCliDir of geminiCliDirs) {
const searchPaths = [
join(
geminiCliDir,
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"src",
"code_assist",
"oauth2.js",
),
join(
geminiCliDir,
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"code_assist",
"oauth2.js",
),
];
for (const p of searchPaths) {
if (existsSync(p)) {
content = readFileSync(p, "utf8");
break;
}
}
if (content) {
for (const p of searchPaths) {
if (existsSync(p)) {
content = readFileSync(p, "utf8");
break;
}
}
if (!content) {
const found = findFile(geminiCliDir, "oauth2.js", 10);
if (found) {
content = readFileSync(found, "utf8");
break;
}
}
if (!content) {
@@ -128,30 +124,6 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret:
return null;
}
function resolveGeminiCliDirs(geminiPath: string, resolvedPath: string): string[] {
const binDir = dirname(geminiPath);
const candidates = [
dirname(dirname(resolvedPath)),
join(dirname(resolvedPath), "node_modules", "@google", "gemini-cli"),
join(binDir, "node_modules", "@google", "gemini-cli"),
join(dirname(binDir), "node_modules", "@google", "gemini-cli"),
join(dirname(binDir), "lib", "node_modules", "@google", "gemini-cli"),
];
const deduped: string[] = [];
const seen = new Set<string>();
for (const candidate of candidates) {
const key =
process.platform === "win32" ? candidate.replace(/\\/g, "/").toLowerCase() : candidate;
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(candidate);
}
return deduped;
}
function findInPath(name: string): string | null {
const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""];
for (const dir of (process.env.PATH ?? "").split(delimiter)) {

View File

@@ -15,7 +15,6 @@ import {
warnMissingProviderGroupPolicyFallbackOnce,
requestBodyErrorToText,
resolveMentionGatingWithBypass,
resolveDmGroupAccessWithLists,
} from "openclaw/plugin-sdk";
import { type ResolvedGoogleChatAccount } from "./accounts.js";
import {
@@ -504,33 +503,14 @@ async function processMessageWithPipeline(params: {
const dmPolicy = account.config.dm?.policy ?? "pairing";
const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v));
const normalizedGroupUsers = groupUsers.map((v) => String(v));
const senderGroupPolicy =
groupPolicy === "disabled"
? "disabled"
: normalizedGroupUsers.length > 0
? "allowlist"
: "open";
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom =
!isGroup && dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeAuth)
? await core.channel.pairing.readAllowFromStore("googlechat").catch(() => [])
: [];
const access = resolveDmGroupAccessWithLists({
isGroup,
dmPolicy,
groupPolicy: senderGroupPolicy,
allowFrom: configAllowFrom,
groupAllowFrom: normalizedGroupUsers,
storeAllowFrom,
groupAllowFromFallbackToAllowFrom: false,
isSenderAllowed: (allowFrom) =>
isSenderAllowed(senderId, senderEmail, allowFrom, allowNameMatching),
});
const effectiveAllowFrom = access.effectiveAllowFrom;
const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom;
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
warnDeprecatedUsersEmailEntries(core, runtime, effectiveAllowFrom);
const commandAllowFrom = isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom;
const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom;
const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed(
senderId,
@@ -573,53 +553,47 @@ async function processMessageWithPipeline(params: {
}
}
if (isGroup && access.decision !== "allow") {
logVerbose(
core,
runtime,
`drop group message (sender policy blocked, reason=${access.reason}, space=${spaceId})`,
);
return;
}
if (!isGroup) {
if (account.config.dm?.enabled === false) {
if (dmPolicy === "disabled" || account.config.dm?.enabled === false) {
logVerbose(core, runtime, `Blocked Google Chat DM from ${senderId} (dmPolicy=disabled)`);
return;
}
if (access.decision !== "allow") {
if (access.decision === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "googlechat",
id: senderId,
meta: { name: senderName || undefined, email: senderEmail },
});
if (created) {
logVerbose(core, runtime, `googlechat pairing request sender=${senderId}`);
try {
await sendGoogleChatMessage({
account,
space: spaceId,
text: core.channel.pairing.buildPairingReply({
channel: "googlechat",
idLine: `Your Google Chat user id: ${senderId}`,
code,
}),
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`);
if (dmPolicy !== "open") {
const allowed = senderAllowedForCommands;
if (!allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "googlechat",
id: senderId,
meta: { name: senderName || undefined, email: senderEmail },
});
if (created) {
logVerbose(core, runtime, `googlechat pairing request sender=${senderId}`);
try {
await sendGoogleChatMessage({
account,
space: spaceId,
text: core.channel.pairing.buildPairingReply({
channel: "googlechat",
idLine: `Your Google Chat user id: ${senderId}`,
code,
}),
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`);
}
}
} else {
logVerbose(
core,
runtime,
`Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`,
);
}
} else {
logVerbose(
core,
runtime,
`Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`,
);
return;
}
return;
}
}

View File

@@ -7,7 +7,6 @@ describe("irc inbound policy", () => {
configAllowFrom: ["owner"],
configGroupAllowFrom: [],
storeAllowList: ["paired-user"],
dmPolicy: "pairing",
});
expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]);
@@ -18,7 +17,6 @@ describe("irc inbound policy", () => {
configAllowFrom: ["owner"],
configGroupAllowFrom: ["group-owner"],
storeAllowList: ["paired-user"],
dmPolicy: "pairing",
});
expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]);
@@ -29,7 +27,6 @@ describe("irc inbound policy", () => {
configAllowFrom: ["owner"],
configGroupAllowFrom: [],
storeAllowList: ["paired-user"],
dmPolicy: "pairing",
});
expect(resolved.effectiveGroupAllowFrom).toEqual([]);

View File

@@ -5,12 +5,10 @@ import {
formatTextWithAttachmentLinks,
logInboundDrop,
isDangerousNameMatchingEnabled,
readStoreAllowFromForDmPolicy,
resolveControlCommandGate,
resolveOutboundMediaUrls,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveEffectiveAllowFromLists,
warnMissingProviderGroupPolicyFallbackOnce,
type OutboundReplyPayload,
type OpenClawConfig,
@@ -37,19 +35,13 @@ function resolveIrcEffectiveAllowlists(params: {
configAllowFrom: string[];
configGroupAllowFrom: string[];
storeAllowList: string[];
dmPolicy: string;
}): {
effectiveAllowFrom: string[];
effectiveGroupAllowFrom: string[];
} {
const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
allowFrom: params.configAllowFrom,
groupAllowFrom: params.configGroupAllowFrom,
storeAllowFrom: params.storeAllowList,
dmPolicy: params.dmPolicy,
// IRC intentionally requires explicit groupAllowFrom; do not fallback to allowFrom.
groupAllowFromFallbackToAllowFrom: false,
});
const effectiveAllowFrom = [...params.configAllowFrom, ...params.storeAllowList].filter(Boolean);
// Pairing-store entries are DM approvals and must not widen group sender authorization.
const effectiveGroupAllowFrom = [...params.configGroupAllowFrom].filter(Boolean);
return { effectiveAllowFrom, effectiveGroupAllowFrom };
}
@@ -121,11 +113,10 @@ export async function handleIrcInbound(params: {
const configAllowFrom = normalizeIrcAllowlist(account.config.allowFrom);
const configGroupAllowFrom = normalizeIrcAllowlist(account.config.groupAllowFrom);
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: CHANNEL_ID,
dmPolicy,
readStore: (provider) => core.channel.pairing.readAllowFromStore(provider),
});
const storeAllowFrom =
dmPolicy === "allowlist"
? []
: await core.channel.pairing.readAllowFromStore(CHANNEL_ID).catch(() => []);
const storeAllowList = normalizeIrcAllowlist(storeAllowFrom);
const groupMatch = resolveIrcGroupMatch({
@@ -150,7 +141,6 @@ export async function handleIrcInbound(params: {
configAllowFrom,
configGroupAllowFrom,
storeAllowList,
dmPolicy,
});
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({

View File

@@ -5,9 +5,7 @@ import {
formatAllowlistMatchMeta,
logInboundDrop,
logTypingFailure,
readStoreAllowFromForDmPolicy,
resolveControlCommandGate,
resolveDmGroupAccessWithLists,
type PluginRuntime,
type RuntimeEnv,
type RuntimeLogger,
@@ -215,82 +213,61 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
}
const senderName = await getMemberDisplayName(roomId, senderId);
const storeAllowFrom = isDirectMessage
? await readStoreAllowFromForDmPolicy({
provider: "matrix",
dmPolicy,
readStore: (provider) => core.channel.pairing.readAllowFromStore(provider),
})
: [];
const storeAllowFrom =
dmPolicy === "allowlist"
? []
: await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]);
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
const normalizedGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom);
const senderGroupPolicy =
groupPolicy === "disabled"
? "disabled"
: normalizedGroupAllowFrom.length > 0
? "allowlist"
: "open";
const access = resolveDmGroupAccessWithLists({
isGroup: isRoom,
dmPolicy,
groupPolicy: senderGroupPolicy,
allowFrom,
groupAllowFrom: normalizedGroupAllowFrom,
storeAllowFrom,
groupAllowFromFallbackToAllowFrom: false,
isSenderAllowed: (allowFrom) =>
resolveMatrixAllowListMatches({
allowList: normalizeMatrixAllowList(allowFrom),
userId: senderId,
}),
});
const effectiveAllowFrom = normalizeMatrixAllowList(access.effectiveAllowFrom);
const effectiveGroupAllowFrom = normalizeMatrixAllowList(access.effectiveGroupAllowFrom);
const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom);
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
if (isDirectMessage) {
if (!dmEnabled) {
if (!dmEnabled || dmPolicy === "disabled") {
return;
}
if (access.decision !== "allow") {
if (dmPolicy !== "open") {
const allowMatch = resolveMatrixAllowListMatch({
allowList: effectiveAllowFrom,
userId: senderId,
});
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (access.decision === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "matrix",
id: senderId,
meta: { name: senderName },
});
if (created) {
logVerboseMessage(
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
);
try {
await sendMessageMatrix(
`room:${roomId}`,
[
"OpenClaw: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"openclaw pairing approve matrix <code>",
].join("\n"),
{ client },
if (!allowMatch.allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "matrix",
id: senderId,
meta: { name: senderName },
});
if (created) {
logVerboseMessage(
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
);
} catch (err) {
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
try {
await sendMessageMatrix(
`room:${roomId}`,
[
"OpenClaw: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"openclaw pairing approve matrix <code>",
].join("\n"),
{ client },
);
} catch (err) {
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
}
}
}
} else {
logVerboseMessage(
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
if (dmPolicy !== "pairing") {
logVerboseMessage(
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
}
return;
}
return;
}
}
@@ -309,7 +286,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
return;
}
}
if (isRoom && roomUsers.length === 0 && groupAllowConfigured && access.decision !== "allow") {
if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) {
const groupAllowMatch = resolveMatrixAllowListMatch({
allowList: effectiveGroupAllowFrom,
userId: senderId,
@@ -678,23 +655,17 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
},
});
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
onSettled: () => {
markDispatchIdle();
replyOptions: {
...replyOptions,
skillFilter: roomConfig?.skills,
onModelSelected,
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
skillFilter: roomConfig?.skills,
onModelSelected,
},
}),
});
markDispatchIdle();
if (!queuedFinal) {
return;
}

View File

@@ -1,58 +0,0 @@
import { resolveAllowlistMatchSimple, resolveEffectiveAllowFromLists } from "openclaw/plugin-sdk";
export function normalizeMattermostAllowEntry(entry: string): string {
const trimmed = entry.trim();
if (!trimmed) {
return "";
}
if (trimmed === "*") {
return "*";
}
return trimmed
.replace(/^(mattermost|user):/i, "")
.replace(/^@/, "")
.toLowerCase();
}
export function normalizeMattermostAllowList(entries: Array<string | number>): string[] {
const normalized = entries
.map((entry) => normalizeMattermostAllowEntry(String(entry)))
.filter(Boolean);
return Array.from(new Set(normalized));
}
export function resolveMattermostEffectiveAllowFromLists(params: {
allowFrom?: Array<string | number> | null;
groupAllowFrom?: Array<string | number> | null;
storeAllowFrom?: Array<string | number> | null;
dmPolicy?: string | null;
}): {
effectiveAllowFrom: string[];
effectiveGroupAllowFrom: string[];
} {
return resolveEffectiveAllowFromLists({
allowFrom: normalizeMattermostAllowList(params.allowFrom ?? []),
groupAllowFrom: normalizeMattermostAllowList(params.groupAllowFrom ?? []),
storeAllowFrom: normalizeMattermostAllowList(params.storeAllowFrom ?? []),
dmPolicy: params.dmPolicy,
});
}
export function isMattermostSenderAllowed(params: {
senderId: string;
senderName?: string;
allowFrom: string[];
allowNameMatching?: boolean;
}): boolean {
const allowFrom = normalizeMattermostAllowList(params.allowFrom);
if (allowFrom.length === 0) {
return false;
}
const match = resolveAllowlistMatchSimple({
allowFrom,
senderId: normalizeMattermostAllowEntry(params.senderId),
senderName: params.senderName ? normalizeMattermostAllowEntry(params.senderName) : undefined,
allowNameMatching: params.allowNameMatching,
});
return match.allowed;
}

View File

@@ -1,37 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveMattermostEffectiveAllowFromLists } from "./monitor-auth.js";
describe("mattermost monitor authz", () => {
it("keeps DM allowlist merged with pairing-store entries", () => {
const resolved = resolveMattermostEffectiveAllowFromLists({
dmPolicy: "pairing",
allowFrom: ["@trusted-user"],
groupAllowFrom: ["@group-owner"],
storeAllowFrom: ["user:attacker"],
});
expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
});
it("uses explicit groupAllowFrom without pairing-store inheritance", () => {
const resolved = resolveMattermostEffectiveAllowFromLists({
dmPolicy: "pairing",
allowFrom: ["@trusted-user"],
groupAllowFrom: ["@group-owner"],
storeAllowFrom: ["user:attacker"],
});
expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]);
});
it("does not inherit pairing-store entries into group allowlist", () => {
const resolved = resolveMattermostEffectiveAllowFromLists({
dmPolicy: "pairing",
allowFrom: ["@trusted-user"],
storeAllowFrom: ["user:attacker"],
});
expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
expect(resolved.effectiveGroupAllowFrom).toEqual(["trusted-user"]);
});
});

View File

@@ -7,7 +7,6 @@ import type {
} from "openclaw/plugin-sdk";
import {
buildAgentMediaPayload,
DM_GROUP_ACCESS_REASON,
createReplyPrefixOptions,
createTypingCallbacks,
logInboundDrop,
@@ -18,7 +17,6 @@ import {
recordPendingHistoryEntryIfEnabled,
isDangerousNameMatchingEnabled,
resolveControlCommandGate,
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
@@ -39,7 +37,6 @@ import {
type MattermostPost,
type MattermostUser,
} from "./client.js";
import { isMattermostSenderAllowed, normalizeMattermostAllowList } from "./monitor-auth.js";
import {
createDedupeCache,
formatInboundFromLabel,
@@ -65,6 +62,7 @@ export type MonitorMattermostOpts = {
webSocketFactory?: MattermostWebSocketFactory;
};
type FetchLike = (input: URL | RequestInfo, init?: RequestInit) => Promise<Response>;
type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
type MattermostReaction = {
@@ -133,6 +131,51 @@ function channelChatType(kind: ChatType): "direct" | "group" | "channel" {
return "channel";
}
function normalizeAllowEntry(entry: string): string {
const trimmed = entry.trim();
if (!trimmed) {
return "";
}
if (trimmed === "*") {
return "*";
}
return trimmed
.replace(/^(mattermost|user):/i, "")
.replace(/^@/, "")
.toLowerCase();
}
function normalizeAllowList(entries: Array<string | number>): string[] {
const normalized = entries.map((entry) => normalizeAllowEntry(String(entry))).filter(Boolean);
return Array.from(new Set(normalized));
}
function isSenderAllowed(params: {
senderId: string;
senderName?: string;
allowFrom: string[];
allowNameMatching?: boolean;
}): boolean {
const allowFrom = params.allowFrom;
if (allowFrom.length === 0) {
return false;
}
if (allowFrom.includes("*")) {
return true;
}
const normalizedSenderId = normalizeAllowEntry(params.senderId);
const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
return allowFrom.some((entry) => {
if (entry === normalizedSenderId) {
return true;
}
if (params.allowNameMatching !== true) {
return false;
}
return normalizedSenderName ? entry === normalizedSenderName : false;
});
}
type MattermostMediaInfo = {
path: string;
contentType?: string;
@@ -225,6 +268,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
log: (message) => logVerboseMessage(message),
});
const fetchWithAuth: FetchLike = (input, init) => {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${client.token}`);
return fetch(input, { ...init, headers });
};
const resolveMattermostMedia = async (
fileIds?: string[] | null,
): Promise<MattermostMediaInfo[]> => {
@@ -237,11 +286,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
try {
const fetched = await core.channel.media.fetchRemoteMedia({
url: `${client.apiBaseUrl}/files/${fileId}`,
requestInit: {
headers: {
Authorization: `Bearer ${client.token}`,
},
},
fetchImpl: fetchWithAuth,
filePathHint: fileId,
maxBytes: mediaMaxBytes,
});
@@ -355,34 +400,20 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
senderId;
const rawText = post.message?.trim() || "";
const dmPolicy = account.config.dmPolicy ?? "pairing";
const normalizedAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []);
const normalizedGroupAllowFrom = normalizeMattermostAllowList(
account.config.groupAllowFrom ?? [],
const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []);
const storeAllowFrom = normalizeAllowList(
dmPolicy === "allowlist"
? []
: await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
);
const storeAllowFrom = normalizeMattermostAllowList(
await readStoreAllowFromForDmPolicy({
provider: "mattermost",
dmPolicy,
readStore: (provider) => core.channel.pairing.readAllowFromStore(provider),
}),
const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom]));
const effectiveGroupAllowFrom = Array.from(
new Set([
...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
...storeAllowFrom,
]),
);
const accessDecision = resolveDmGroupAccessWithLists({
isGroup: kind !== "direct",
dmPolicy,
groupPolicy,
allowFrom: normalizedAllowFrom,
groupAllowFrom: normalizedGroupAllowFrom,
storeAllowFrom,
isSenderAllowed: (allowFrom) =>
isMattermostSenderAllowed({
senderId,
senderName,
allowFrom,
allowNameMatching,
}),
});
const effectiveAllowFrom = accessDecision.effectiveAllowFrom;
const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom;
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg,
surface: "mattermost",
@@ -390,14 +421,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg);
const isControlCommand = allowTextCommands && hasControlCommand;
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : normalizedAllowFrom;
const senderAllowedForCommands = isMattermostSenderAllowed({
const senderAllowedForCommands = isSenderAllowed({
senderId,
senderName,
allowFrom: commandDmAllowFrom,
allowFrom: effectiveAllowFrom,
allowNameMatching,
});
const groupAllowedForCommands = isMattermostSenderAllowed({
const groupAllowedForCommands = isSenderAllowed({
senderId,
senderName,
allowFrom: effectiveGroupAllowFrom,
@@ -406,7 +436,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands },
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{
configured: effectiveGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands,
@@ -416,15 +446,17 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
hasControlCommand,
});
const commandAuthorized =
kind === "direct" ? accessDecision.decision === "allow" : commandGate.commandAuthorized;
kind === "direct"
? dmPolicy === "open" || senderAllowedForCommands
: commandGate.commandAuthorized;
if (accessDecision.decision !== "allow") {
if (kind === "direct") {
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) {
logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`);
return;
}
if (accessDecision.decision === "pairing") {
if (kind === "direct") {
if (dmPolicy === "disabled") {
logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`);
return;
}
if (dmPolicy !== "open" && !senderAllowedForCommands) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "mattermost",
id: senderId,
@@ -447,27 +479,26 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
logVerboseMessage(`mattermost: pairing reply failed for ${senderId}: ${String(err)}`);
}
}
return;
} else {
logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`);
}
logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`);
return;
}
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) {
} else {
if (groupPolicy === "disabled") {
logVerboseMessage("mattermost: drop group message (groupPolicy=disabled)");
return;
}
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) {
logVerboseMessage("mattermost: drop group message (no group allowlist)");
return;
if (groupPolicy === "allowlist") {
if (effectiveGroupAllowFrom.length === 0) {
logVerboseMessage("mattermost: drop group message (no group allowlist)");
return;
}
if (!groupAllowedForCommands) {
logVerboseMessage(`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`);
return;
}
}
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) {
logVerboseMessage(`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`);
return;
}
logVerboseMessage(
`mattermost: drop group message (groupPolicy=${groupPolicy} reason=${accessDecision.reason})`,
);
return;
}
if (kind !== "direct" && commandGate.shouldBlock) {
@@ -777,24 +808,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
},
});
await core.channel.reply.withReplyDispatcher({
await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
onSettled: () => {
markDispatchIdle();
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
onModelSelected,
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
onModelSelected,
},
}),
});
markDispatchIdle();
if (historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: channelHistories,
@@ -860,25 +885,23 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
// Enforce DM/group policy and allowlist checks (same as normal messages)
const dmPolicy = account.config.dmPolicy ?? "pairing";
const storeAllowFrom = normalizeMattermostAllowList(
await readStoreAllowFromForDmPolicy({
provider: "mattermost",
dmPolicy,
readStore: (provider) => core.channel.pairing.readAllowFromStore(provider),
}),
const storeAllowFrom = normalizeAllowList(
dmPolicy === "allowlist"
? []
: await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
);
const reactionAccess = resolveDmGroupAccessWithLists({
isGroup: kind !== "direct",
dmPolicy,
groupPolicy,
allowFrom: normalizeMattermostAllowList(account.config.allowFrom ?? []),
groupAllowFrom: normalizeMattermostAllowList(account.config.groupAllowFrom ?? []),
allowFrom: account.config.allowFrom,
groupAllowFrom: account.config.groupAllowFrom,
storeAllowFrom,
isSenderAllowed: (allowFrom) =>
isMattermostSenderAllowed({
isSenderAllowed({
senderId: userId,
senderName,
allowFrom,
allowFrom: normalizeAllowList(allowFrom),
allowNameMatching,
}),
});

View File

@@ -1,4 +1,4 @@
import type { PluginRuntime, SsrFPolicy } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildMSTeamsAttachmentPlaceholder,
@@ -9,6 +9,16 @@ import {
} from "./attachments.js";
import { setMSTeamsRuntime } from "./runtime.js";
vi.mock("openclaw/plugin-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk")>();
return {
...actual,
isPrivateIpAddress: () => false,
};
});
/** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */
const publicResolveFn = async () => ({ address: "13.107.136.10" });
const GRAPH_HOST = "graph.microsoft.com";
const SHAREPOINT_HOST = "contoso.sharepoint.com";
const AZUREEDGE_HOST = "azureedge.net";
@@ -40,7 +50,6 @@ type RemoteMediaFetchParams = {
url: string;
maxBytes?: number;
filePathHint?: string;
ssrfPolicy?: SsrFPolicy;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
};
@@ -66,44 +75,10 @@ const readRemoteMediaResponse = async (
fileName: params.filePathHint,
};
};
function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean {
if (pattern.startsWith("*.")) {
const suffix = pattern.slice(2);
return suffix.length > 0 && hostname !== suffix && hostname.endsWith(`.${suffix}`);
}
return hostname === pattern;
}
function isUrlAllowedBySsrfPolicy(url: string, policy?: SsrFPolicy): boolean {
if (!policy?.hostnameAllowlist || policy.hostnameAllowlist.length === 0) {
return true;
}
const hostname = new URL(url).hostname.toLowerCase();
return policy.hostnameAllowlist.some((pattern) =>
isHostnameAllowedByPattern(hostname, pattern.toLowerCase()),
);
}
const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
const fetchFn = params.fetchImpl ?? fetch;
let currentUrl = params.url;
for (let i = 0; i <= MAX_REDIRECT_HOPS; i += 1) {
if (!isUrlAllowedBySsrfPolicy(currentUrl, params.ssrfPolicy)) {
throw new Error(`Blocked hostname (not in allowlist): ${currentUrl}`);
}
const res = await fetchFn(currentUrl, { redirect: "manual" });
if (REDIRECT_STATUS_CODES.includes(res.status)) {
const location = res.headers.get("location");
if (!location) {
throw new Error("redirect missing location");
}
currentUrl = new URL(location, currentUrl).toString();
continue;
}
return readRemoteMediaResponse(res, params);
}
throw new Error("too many redirects");
const res = await fetchFn(params.url);
return readRemoteMediaResponse(res, params);
});
const runtimeStub = {
@@ -125,13 +100,16 @@ type DownloadGraphMediaParams = Parameters<typeof downloadMSTeamsGraphMedia>[0];
type DownloadedMedia = Awaited<ReturnType<typeof downloadMSTeamsAttachments>>;
type MSTeamsMediaPayload = ReturnType<typeof buildMSTeamsMediaPayload>;
type DownloadAttachmentsBuildOverrides = Partial<
Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts">
Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts" | "resolveFn">
> &
Pick<DownloadAttachmentsParams, "allowHosts">;
Pick<DownloadAttachmentsParams, "allowHosts" | "resolveFn">;
type DownloadAttachmentsNoFetchOverrides = Partial<
Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts" | "fetchFn">
Omit<
DownloadAttachmentsParams,
"attachments" | "maxBytes" | "allowHosts" | "resolveFn" | "fetchFn"
>
> &
Pick<DownloadAttachmentsParams, "allowHosts">;
Pick<DownloadAttachmentsParams, "allowHosts" | "resolveFn">;
type DownloadGraphMediaOverrides = Partial<
Omit<DownloadGraphMediaParams, "messageUrl" | "tokenProvider" | "maxBytes">
>;
@@ -232,6 +210,7 @@ const buildDownloadParams = (
attachments,
maxBytes: DEFAULT_MAX_BYTES,
allowHosts: DEFAULT_ALLOW_HOSTS,
resolveFn: publicResolveFn,
...overrides,
};
};
@@ -701,37 +680,13 @@ describe("msteams attachments", () => {
fetchMock,
{
allowHosts: [GRAPH_HOST],
resolveFn: undefined,
},
{ expectFetchCalled: false },
);
expectAttachmentMediaLength(media, 0);
});
it("blocks redirects to non-https URLs", async () => {
const insecureUrl = "http://x/insecure.png";
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url === TEST_URL_IMAGE) {
return createRedirectResponse(insecureUrl);
}
if (url === insecureUrl) {
return createBufferResponse("insecure", CONTENT_TYPE_IMAGE_PNG);
}
return createNotFoundResponse();
});
const media = await downloadAttachmentsWithFetch(
createImageAttachments(TEST_URL_IMAGE),
fetchMock,
{
allowHosts: [TEST_HOST],
},
);
expectAttachmentMediaLength(media, 0);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe("buildMSTeamsGraphMessageUrls", () => {
@@ -746,6 +701,24 @@ describe("msteams attachments", () => {
it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
const escapedUrl = "https://evil.example/internal.pdf";
fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
const fetchFn = params.fetchImpl ?? fetch;
let currentUrl = params.url;
for (let i = 0; i < MAX_REDIRECT_HOPS; i += 1) {
const res = await fetchFn(currentUrl, { redirect: "manual" });
if (REDIRECT_STATUS_CODES.includes(res.status)) {
const location = res.headers.get("location");
if (!location) {
throw new Error("redirect missing location");
}
currentUrl = new URL(location, currentUrl).toString();
continue;
}
return readRemoteMediaResponse(res, params);
}
throw new Error("too many redirects");
});
const { fetchMock, media } = await downloadGraphMediaWithMockOptions(
{
...buildDefaultShareReferenceGraphFetchOptions({

View File

@@ -1,4 +1,3 @@
import { fetchWithBearerAuthScopeFallback } from "openclaw/plugin-sdk";
import { getMSTeamsRuntime } from "../runtime.js";
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
import {
@@ -8,10 +7,10 @@ import {
isRecord,
isUrlAllowed,
normalizeContentType,
resolveMediaSsrfPolicy,
resolveRequestUrl,
resolveAuthAllowedHosts,
resolveAllowedHosts,
safeFetch,
} from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
@@ -91,17 +90,81 @@ async function fetchWithAuthFallback(params: {
tokenProvider?: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
requestInit?: RequestInit;
allowHosts: string[];
authAllowHosts: string[];
resolveFn?: (hostname: string) => Promise<{ address: string }>;
}): Promise<Response> {
return await fetchWithBearerAuthScopeFallback({
const fetchFn = params.fetchFn ?? fetch;
// Use safeFetch for the initial attempt — redirect: "manual" with
// allowlist + DNS/IP validation on every hop (prevents SSRF via redirect).
const firstAttempt = await safeFetch({
url: params.url,
scopes: scopeCandidatesForUrl(params.url),
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
allowHosts: params.allowHosts,
fetchFn,
requestInit: params.requestInit,
requireHttps: true,
shouldAttachAuth: (url) => isUrlAllowed(url, params.authAllowHosts),
resolveFn: params.resolveFn,
});
if (firstAttempt.ok) {
return firstAttempt;
}
if (!params.tokenProvider) {
return firstAttempt;
}
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) {
return firstAttempt;
}
if (!isUrlAllowed(params.url, params.authAllowHosts)) {
return firstAttempt;
}
const scopes = scopeCandidatesForUrl(params.url);
for (const scope of scopes) {
try {
const token = await params.tokenProvider.getAccessToken(scope);
const authHeaders = new Headers(params.requestInit?.headers);
authHeaders.set("Authorization", `Bearer ${token}`);
const authAttempt = await safeFetch({
url: params.url,
allowHosts: params.allowHosts,
fetchFn,
requestInit: {
...params.requestInit,
headers: authHeaders,
},
resolveFn: params.resolveFn,
});
if (authAttempt.ok) {
return authAttempt;
}
if (authAttempt.status !== 401 && authAttempt.status !== 403) {
continue;
}
const finalUrl =
typeof authAttempt.url === "string" && authAttempt.url ? authAttempt.url : "";
if (!finalUrl || finalUrl === params.url || !isUrlAllowed(finalUrl, params.authAllowHosts)) {
continue;
}
const redirectedAuthAttempt = await safeFetch({
url: finalUrl,
allowHosts: params.allowHosts,
fetchFn,
requestInit: {
...params.requestInit,
headers: authHeaders,
},
resolveFn: params.resolveFn,
});
if (redirectedAuthAttempt.ok) {
return redirectedAuthAttempt;
}
} catch {
// Try the next scope.
}
}
return firstAttempt;
}
/**
@@ -117,6 +180,8 @@ export async function downloadMSTeamsAttachments(params: {
fetchFn?: typeof fetch;
/** When true, embeds original filename in stored path for later extraction. */
preserveFilenames?: boolean;
/** Override DNS resolver for testing (anti-SSRF IP validation). */
resolveFn?: (hostname: string) => Promise<{ address: string }>;
}): Promise<MSTeamsInboundMedia[]> {
const list = Array.isArray(params.attachments) ? params.attachments : [];
if (list.length === 0) {
@@ -124,7 +189,6 @@ export async function downloadMSTeamsAttachments(params: {
}
const allowHosts = resolveAllowedHosts(params.allowHosts);
const authAllowHosts = resolveAuthAllowedHosts(params.authAllowHosts);
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
// Download ANY downloadable attachment (not just images)
const downloadable = list.filter(isDownloadableAttachment);
@@ -193,14 +257,15 @@ export async function downloadMSTeamsAttachments(params: {
contentTypeHint: candidate.contentTypeHint,
placeholder: candidate.placeholder,
preserveFilenames: params.preserveFilenames,
ssrfPolicy,
fetchImpl: (input, init) =>
fetchWithAuthFallback({
url: resolveRequestUrl(input),
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
requestInit: init,
allowHosts,
authAllowHosts,
resolveFn: params.resolveFn,
}),
});
out.push(media);

View File

@@ -1,4 +1,3 @@
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk";
import { getMSTeamsRuntime } from "../runtime.js";
import { downloadMSTeamsAttachments } from "./download.js";
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
@@ -8,9 +7,9 @@ import {
isRecord,
isUrlAllowed,
normalizeContentType,
resolveMediaSsrfPolicy,
resolveRequestUrl,
resolveAllowedHosts,
safeFetch,
} from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
@@ -120,31 +119,20 @@ async function fetchGraphCollection<T>(params: {
url: string;
accessToken: string;
fetchFn?: typeof fetch;
ssrfPolicy?: SsrFPolicy;
}): Promise<{ status: number; items: T[] }> {
const fetchFn = params.fetchFn ?? fetch;
const { response, release } = await fetchWithSsrFGuard({
url: params.url,
fetchImpl: fetchFn,
init: {
headers: { Authorization: `Bearer ${params.accessToken}` },
},
policy: params.ssrfPolicy,
auditContext: "msteams.graph.collection",
const res = await fetchFn(params.url, {
headers: { Authorization: `Bearer ${params.accessToken}` },
});
const status = res.status;
if (!res.ok) {
return { status, items: [] };
}
try {
const status = response.status;
if (!response.ok) {
return { status, items: [] };
}
try {
const data = (await response.json()) as { value?: T[] };
return { status, items: Array.isArray(data.value) ? data.value : [] };
} catch {
return { status, items: [] };
}
} finally {
await release();
const data = (await res.json()) as { value?: T[] };
return { status, items: Array.isArray(data.value) ? data.value : [] };
} catch {
return { status, items: [] };
}
}
@@ -176,13 +164,11 @@ async function downloadGraphHostedContent(params: {
maxBytes: number;
fetchFn?: typeof fetch;
preserveFilenames?: boolean;
ssrfPolicy?: SsrFPolicy;
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
const hosted = await fetchGraphCollection<GraphHostedContent>({
url: `${params.messageUrl}/hostedContents`,
accessToken: params.accessToken,
fetchFn: params.fetchFn,
ssrfPolicy: params.ssrfPolicy,
});
if (hosted.items.length === 0) {
return { media: [], status: hosted.status, count: 0 };
@@ -242,7 +228,6 @@ export async function downloadMSTeamsGraphMedia(params: {
return { media: [] };
}
const allowHosts = resolveAllowedHosts(params.allowHosts);
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
const messageUrl = params.messageUrl;
let accessToken: string;
try {
@@ -256,67 +241,64 @@ export async function downloadMSTeamsGraphMedia(params: {
const sharePointMedia: MSTeamsInboundMedia[] = [];
const downloadedReferenceUrls = new Set<string>();
try {
const { response: msgRes, release } = await fetchWithSsrFGuard({
url: messageUrl,
fetchImpl: fetchFn,
init: {
headers: { Authorization: `Bearer ${accessToken}` },
},
policy: ssrfPolicy,
auditContext: "msteams.graph.message",
const msgRes = await fetchFn(messageUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
});
try {
if (msgRes.ok) {
const msgData = (await msgRes.json()) as {
body?: { content?: string; contentType?: string };
attachments?: Array<{
id?: string;
contentUrl?: string;
contentType?: string;
name?: string;
}>;
};
if (msgRes.ok) {
const msgData = (await msgRes.json()) as {
body?: { content?: string; contentType?: string };
attachments?: Array<{
id?: string;
contentUrl?: string;
contentType?: string;
name?: string;
}>;
};
// Extract SharePoint file attachments (contentType: "reference")
// Download any file type, not just images
const spAttachments = (msgData.attachments ?? []).filter(
(a) => a.contentType === "reference" && a.contentUrl && a.name,
);
for (const att of spAttachments) {
const name = att.name ?? "file";
// Extract SharePoint file attachments (contentType: "reference")
// Download any file type, not just images
const spAttachments = (msgData.attachments ?? []).filter(
(a) => a.contentType === "reference" && a.contentUrl && a.name,
);
for (const att of spAttachments) {
const name = att.name ?? "file";
try {
// SharePoint URLs need to be accessed via Graph shares API
const shareUrl = att.contentUrl!;
if (!isUrlAllowed(shareUrl, allowHosts)) {
continue;
}
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
const media = await downloadAndStoreMSTeamsRemoteMedia({
url: sharesUrl,
filePathHint: name,
maxBytes: params.maxBytes,
contentTypeHint: "application/octet-stream",
preserveFilenames: params.preserveFilenames,
ssrfPolicy,
fetchImpl: async (input, init) => {
const requestUrl = resolveRequestUrl(input);
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${accessToken}`);
return await fetchFn(requestUrl, { ...init, headers });
},
});
sharePointMedia.push(media);
downloadedReferenceUrls.add(shareUrl);
} catch {
// Ignore SharePoint download failures.
try {
// SharePoint URLs need to be accessed via Graph shares API
const shareUrl = att.contentUrl!;
if (!isUrlAllowed(shareUrl, allowHosts)) {
continue;
}
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
const media = await downloadAndStoreMSTeamsRemoteMedia({
url: sharesUrl,
filePathHint: name,
maxBytes: params.maxBytes,
contentTypeHint: "application/octet-stream",
preserveFilenames: params.preserveFilenames,
fetchImpl: async (input, init) => {
const requestUrl = resolveRequestUrl(input);
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${accessToken}`);
return await safeFetch({
url: requestUrl,
allowHosts,
fetchFn,
requestInit: {
...init,
headers,
},
});
},
});
sharePointMedia.push(media);
downloadedReferenceUrls.add(shareUrl);
} catch {
// Ignore SharePoint download failures.
}
}
} finally {
await release();
}
} catch {
// Ignore message fetch failures.
@@ -328,14 +310,12 @@ export async function downloadMSTeamsGraphMedia(params: {
maxBytes: params.maxBytes,
fetchFn: params.fetchFn,
preserveFilenames: params.preserveFilenames,
ssrfPolicy,
});
const attachments = await fetchGraphCollection<GraphAttachment>({
url: `${messageUrl}/attachments`,
accessToken,
fetchFn: params.fetchFn,
ssrfPolicy,
});
const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);

View File

@@ -1,4 +1,3 @@
import type { SsrFPolicy } from "openclaw/plugin-sdk";
import { getMSTeamsRuntime } from "../runtime.js";
import { inferPlaceholder } from "./shared.js";
import type { MSTeamsInboundMedia } from "./types.js";
@@ -10,7 +9,6 @@ export async function downloadAndStoreMSTeamsRemoteMedia(params: {
filePathHint: string;
maxBytes: number;
fetchImpl?: FetchLike;
ssrfPolicy?: SsrFPolicy;
contentTypeHint?: string;
placeholder?: string;
preserveFilenames?: boolean;
@@ -20,7 +18,6 @@ export async function downloadAndStoreMSTeamsRemoteMedia(params: {
fetchImpl: params.fetchImpl,
filePathHint: params.filePathHint,
maxBytes: params.maxBytes,
ssrfPolicy: params.ssrfPolicy,
});
const mime = await getMSTeamsRuntime().media.detectMime({
buffer: fetched.buffer,

View File

@@ -1,28 +1,281 @@
import { describe, expect, it } from "vitest";
import {
isUrlAllowed,
resolveAllowedHosts,
resolveAuthAllowedHosts,
resolveMediaSsrfPolicy,
} from "./shared.js";
import { describe, expect, it, vi } from "vitest";
import { isPrivateOrReservedIP, resolveAndValidateIP, safeFetch } from "./shared.js";
describe("msteams attachment allowlists", () => {
it("normalizes wildcard host lists", () => {
expect(resolveAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
expect(resolveAuthAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
// ─── Helpers ─────────────────────────────────────────────────────────────────
const publicResolve = async () => ({ address: "13.107.136.10" });
const privateResolve = (ip: string) => async () => ({ address: ip });
const failingResolve = async () => {
throw new Error("DNS failure");
};
function mockFetchWithRedirect(redirectMap: Record<string, string>, finalBody = "ok") {
return vi.fn(async (url: string, init?: RequestInit) => {
const target = redirectMap[url];
if (target && init?.redirect === "manual") {
return new Response(null, {
status: 302,
headers: { location: target },
});
}
return new Response(finalBody, { status: 200 });
});
}
// ─── isPrivateOrReservedIP ───────────────────────────────────────────────────
describe("isPrivateOrReservedIP", () => {
it.each([
["10.0.0.1", true],
["10.255.255.255", true],
["172.16.0.1", true],
["172.31.255.255", true],
["172.15.0.1", false],
["172.32.0.1", false],
["192.168.0.1", true],
["192.168.255.255", true],
["127.0.0.1", true],
["127.255.255.255", true],
["169.254.0.1", true],
["169.254.169.254", true],
["0.0.0.0", true],
["8.8.8.8", false],
["13.107.136.10", false],
["52.96.0.1", false],
] as const)("IPv4 %s → %s", (ip, expected) => {
expect(isPrivateOrReservedIP(ip)).toBe(expected);
});
it("requires https and host suffix match", () => {
const allowHosts = resolveAllowedHosts(["sharepoint.com"]);
expect(isUrlAllowed("https://contoso.sharepoint.com/file.png", allowHosts)).toBe(true);
expect(isUrlAllowed("http://contoso.sharepoint.com/file.png", allowHosts)).toBe(false);
expect(isUrlAllowed("https://evil.example.com/file.png", allowHosts)).toBe(false);
it.each([
["::1", true],
["::", true],
["fe80::1", true],
["fc00::1", true],
["fd12:3456::1", true],
["2001:0db8::1", false],
["2620:1ec:c11::200", false],
// IPv4-mapped IPv6 addresses
["::ffff:127.0.0.1", true],
["::ffff:10.0.0.1", true],
["::ffff:192.168.1.1", true],
["::ffff:169.254.169.254", true],
["::ffff:8.8.8.8", false],
["::ffff:13.107.136.10", false],
] as const)("IPv6 %s → %s", (ip, expected) => {
expect(isPrivateOrReservedIP(ip)).toBe(expected);
});
it("builds shared SSRF policy from suffix allowlist", () => {
expect(resolveMediaSsrfPolicy(["sharepoint.com"])).toEqual({
hostnameAllowlist: ["sharepoint.com", "*.sharepoint.com"],
});
expect(resolveMediaSsrfPolicy(["*"])).toBeUndefined();
it.each([
["999.999.999.999", true],
["256.0.0.1", true],
["10.0.0.256", true],
["-1.0.0.1", false],
["1.2.3.4.5", false],
["0:0:0:0:0:0:0:1", true],
] as const)("malformed/expanded %s → %s (SDK fails closed)", (ip, expected) => {
expect(isPrivateOrReservedIP(ip)).toBe(expected);
});
});
// ─── resolveAndValidateIP ────────────────────────────────────────────────────
describe("resolveAndValidateIP", () => {
it("accepts a hostname resolving to a public IP", async () => {
const ip = await resolveAndValidateIP("teams.sharepoint.com", publicResolve);
expect(ip).toBe("13.107.136.10");
});
it("rejects a hostname resolving to 10.x.x.x", async () => {
await expect(resolveAndValidateIP("evil.test", privateResolve("10.0.0.1"))).rejects.toThrow(
"private/reserved IP",
);
});
it("rejects a hostname resolving to 169.254.169.254", async () => {
await expect(
resolveAndValidateIP("evil.test", privateResolve("169.254.169.254")),
).rejects.toThrow("private/reserved IP");
});
it("rejects a hostname resolving to loopback", async () => {
await expect(resolveAndValidateIP("evil.test", privateResolve("127.0.0.1"))).rejects.toThrow(
"private/reserved IP",
);
});
it("rejects a hostname resolving to IPv6 loopback", async () => {
await expect(resolveAndValidateIP("evil.test", privateResolve("::1"))).rejects.toThrow(
"private/reserved IP",
);
});
it("throws on DNS resolution failure", async () => {
await expect(resolveAndValidateIP("nonexistent.test", failingResolve)).rejects.toThrow(
"DNS resolution failed",
);
});
});
// ─── safeFetch ───────────────────────────────────────────────────────────────
describe("safeFetch", () => {
it("fetches a URL directly when no redirect occurs", async () => {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
return new Response("ok", { status: 200 });
});
const res = await safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledOnce();
// Should have used redirect: "manual"
expect(fetchMock.mock.calls[0][1]).toHaveProperty("redirect", "manual");
});
it("follows a redirect to an allowlisted host with public IP", async () => {
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": "https://cdn.sharepoint.com/storage/file.pdf",
});
const res = await safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("blocks a redirect to a non-allowlisted host", async () => {
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": "https://evil.example.com/steal",
});
await expect(
safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
}),
).rejects.toThrow("blocked by allowlist");
// Should not have fetched the evil URL
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("blocks a redirect to an allowlisted host that resolves to a private IP (DNS rebinding)", async () => {
let callCount = 0;
const rebindingResolve = async () => {
callCount++;
// First call (initial URL) resolves to public IP
if (callCount === 1) return { address: "13.107.136.10" };
// Second call (redirect target) resolves to private IP
return { address: "169.254.169.254" };
};
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": "https://evil.trafficmanager.net/metadata",
});
await expect(
safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com", "trafficmanager.net"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: rebindingResolve,
}),
).rejects.toThrow("private/reserved IP");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("blocks when the initial URL resolves to a private IP", async () => {
const fetchMock = vi.fn();
await expect(
safeFetch({
url: "https://evil.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: privateResolve("10.0.0.1"),
}),
).rejects.toThrow("Initial download URL blocked");
expect(fetchMock).not.toHaveBeenCalled();
});
it("blocks when initial URL DNS resolution fails", async () => {
const fetchMock = vi.fn();
await expect(
safeFetch({
url: "https://nonexistent.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: failingResolve,
}),
).rejects.toThrow("Initial download URL blocked");
expect(fetchMock).not.toHaveBeenCalled();
});
it("follows multiple redirects when all are valid", async () => {
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url === "https://a.sharepoint.com/1" && init?.redirect === "manual") {
return new Response(null, {
status: 302,
headers: { location: "https://b.sharepoint.com/2" },
});
}
if (url === "https://b.sharepoint.com/2" && init?.redirect === "manual") {
return new Response(null, {
status: 302,
headers: { location: "https://c.sharepoint.com/3" },
});
}
return new Response("final", { status: 200 });
});
const res = await safeFetch({
url: "https://a.sharepoint.com/1",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("throws on too many redirects", async () => {
let counter = 0;
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.redirect === "manual") {
counter++;
return new Response(null, {
status: 302,
headers: { location: `https://loop${counter}.sharepoint.com/x` },
});
}
return new Response("ok", { status: 200 });
});
await expect(
safeFetch({
url: "https://start.sharepoint.com/x",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
}),
).rejects.toThrow("Too many redirects");
});
it("blocks redirect to HTTP (non-HTTPS)", async () => {
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file": "http://internal.sharepoint.com/file",
});
await expect(
safeFetch({
url: "https://teams.sharepoint.com/file",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
}),
).rejects.toThrow("blocked by allowlist");
});
});

View File

@@ -1,9 +1,5 @@
import {
buildHostnameAllowlistPolicyFromSuffixAllowlist,
isHttpsUrlAllowedByHostnameSuffixAllowlist,
normalizeHostnameSuffixAllowlist,
} from "openclaw/plugin-sdk";
import type { SsrFPolicy } from "openclaw/plugin-sdk";
import { lookup } from "node:dns/promises";
import { isPrivateIpAddress } from "openclaw/plugin-sdk";
import type { MSTeamsAttachmentLike } from "./types.js";
type InlineImageCandidate =
@@ -256,18 +252,153 @@ export function safeHostForUrl(url: string): string {
}
}
function normalizeAllowHost(value: string): string {
const trimmed = value.trim().toLowerCase();
if (!trimmed) {
return "";
}
if (trimmed === "*") {
return "*";
}
return trimmed.replace(/^\*\.?/, "");
}
export function resolveAllowedHosts(input?: string[]): string[] {
return normalizeHostnameSuffixAllowlist(input, DEFAULT_MEDIA_HOST_ALLOWLIST);
if (!Array.isArray(input) || input.length === 0) {
return DEFAULT_MEDIA_HOST_ALLOWLIST.slice();
}
const normalized = input.map(normalizeAllowHost).filter(Boolean);
if (normalized.includes("*")) {
return ["*"];
}
return normalized;
}
export function resolveAuthAllowedHosts(input?: string[]): string[] {
return normalizeHostnameSuffixAllowlist(input, DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST);
if (!Array.isArray(input) || input.length === 0) {
return DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST.slice();
}
const normalized = input.map(normalizeAllowHost).filter(Boolean);
if (normalized.includes("*")) {
return ["*"];
}
return normalized;
}
function isHostAllowed(host: string, allowlist: string[]): boolean {
if (allowlist.includes("*")) {
return true;
}
const normalized = host.toLowerCase();
return allowlist.some((entry) => normalized === entry || normalized.endsWith(`.${entry}`));
}
export function isUrlAllowed(url: string, allowlist: string[]): boolean {
return isHttpsUrlAllowedByHostnameSuffixAllowlist(url, allowlist);
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
return false;
}
return isHostAllowed(parsed.hostname, allowlist);
} catch {
return false;
}
}
export function resolveMediaSsrfPolicy(allowHosts: string[]): SsrFPolicy | undefined {
return buildHostnameAllowlistPolicyFromSuffixAllowlist(allowHosts);
/**
* Returns true if the given IPv4 or IPv6 address is in a private, loopback,
* or link-local range that must never be reached from media downloads.
*
* Delegates to the SDK's `isPrivateIpAddress` which handles IPv4-mapped IPv6,
* expanded notation, NAT64, 6to4, Teredo, octal IPv4, and fails closed on
* parse errors.
*/
export const isPrivateOrReservedIP: (ip: string) => boolean = isPrivateIpAddress;
/**
* Resolve a hostname via DNS and reject private/reserved IPs.
* Throws if the resolved IP is private or resolution fails.
*/
export async function resolveAndValidateIP(
hostname: string,
resolveFn?: (hostname: string) => Promise<{ address: string }>,
): Promise<string> {
const resolve = resolveFn ?? lookup;
let resolved: { address: string };
try {
resolved = await resolve(hostname);
} catch {
throw new Error(`DNS resolution failed for "${hostname}"`);
}
if (isPrivateOrReservedIP(resolved.address)) {
throw new Error(`Hostname "${hostname}" resolves to private/reserved IP (${resolved.address})`);
}
return resolved.address;
}
/** Maximum number of redirects to follow in safeFetch. */
const MAX_SAFE_REDIRECTS = 5;
/**
* Fetch a URL with redirect: "manual", validating each redirect target
* against the hostname allowlist and DNS-resolved IP (anti-SSRF).
*
* This prevents:
* - Auto-following redirects to non-allowlisted hosts
* - DNS rebinding attacks where an allowlisted domain resolves to a private IP
*/
export async function safeFetch(params: {
url: string;
allowHosts: string[];
fetchFn?: typeof fetch;
requestInit?: RequestInit;
resolveFn?: (hostname: string) => Promise<{ address: string }>;
}): Promise<Response> {
const fetchFn = params.fetchFn ?? fetch;
const resolveFn = params.resolveFn;
let currentUrl = params.url;
// Validate the initial URL's resolved IP
try {
const initialHost = new URL(currentUrl).hostname;
await resolveAndValidateIP(initialHost, resolveFn);
} catch {
throw new Error(`Initial download URL blocked: ${currentUrl}`);
}
for (let i = 0; i <= MAX_SAFE_REDIRECTS; i++) {
const res = await fetchFn(currentUrl, {
...params.requestInit,
redirect: "manual",
});
if (![301, 302, 303, 307, 308].includes(res.status)) {
return res;
}
const location = res.headers.get("location");
if (!location) {
return res;
}
let redirectUrl: string;
try {
redirectUrl = new URL(location, currentUrl).toString();
} catch {
throw new Error(`Invalid redirect URL: ${location}`);
}
// Validate redirect target against hostname allowlist
if (!isUrlAllowed(redirectUrl, params.allowHosts)) {
throw new Error(`Media redirect target blocked by allowlist: ${redirectUrl}`);
}
// Validate redirect target's resolved IP
const redirectHost = new URL(redirectUrl).hostname;
await resolveAndValidateIP(redirectHost, resolveFn);
currentUrl = redirectUrl;
}
throw new Error(`Too many redirects (>${MAX_SAFE_REDIRECTS})`);
}

View File

@@ -92,12 +92,12 @@ describe("msteams messenger", () => {
expect(messages).toEqual([]);
});
it("does not filter non-exact silent reply prefixes", () => {
it("filters silent reply prefixes", () => {
const messages = renderReplyPayloadsToMessages(
[{ text: `${SILENT_REPLY_TOKEN} -- ignored` }],
{ textChunkLimit: 4000, tableMode: "code" },
);
expect(messages).toEqual([{ text: `${SILENT_REPLY_TOKEN} -- ignored` }]);
expect(messages).toEqual([]);
});
it("splits media into separate messages by default", () => {

View File

@@ -148,24 +148,18 @@ describe("msteams file consent invoke authz", () => {
await handler.run?.(context);
// invokeResponse should be sent immediately
expect(sendActivity).toHaveBeenCalledWith(
expect.objectContaining({
type: "invokeResponse",
}),
);
// Wait for async upload to complete
await vi.waitFor(() => {
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
});
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://upload.example.com/put",
}),
);
expect(getPendingUpload(uploadId)).toBeUndefined();
expect(sendActivity).toHaveBeenCalledWith(
expect.objectContaining({
type: "invokeResponse",
}),
);
});
it("rejects cross-conversation accept invoke and keeps pending upload", async () => {
@@ -185,22 +179,16 @@ describe("msteams file consent invoke authz", () => {
await handler.run?.(context);
// invokeResponse should be sent immediately
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
expect(getPendingUpload(uploadId)).toBeDefined();
expect(sendActivity).toHaveBeenCalledWith(
"The file upload request has expired. Please try sending the file again.",
);
expect(sendActivity).toHaveBeenCalledWith(
expect.objectContaining({
type: "invokeResponse",
}),
);
// Wait for async handler to complete
await vi.waitFor(() => {
expect(sendActivity).toHaveBeenCalledWith(
"The file upload request has expired. Please try sending the file again.",
);
});
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
expect(getPendingUpload(uploadId)).toBeDefined();
});
it("ignores cross-conversation decline invoke and keeps pending upload", async () => {
@@ -220,15 +208,13 @@ describe("msteams file consent invoke authz", () => {
await handler.run?.(context);
// invokeResponse should be sent immediately
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
expect(getPendingUpload(uploadId)).toBeDefined();
expect(sendActivity).toHaveBeenCalledTimes(1);
expect(sendActivity).toHaveBeenCalledWith(
expect.objectContaining({
type: "invokeResponse",
}),
);
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
expect(getPendingUpload(uploadId)).toBeDefined();
expect(sendActivity).toHaveBeenCalledTimes(1);
});
});

View File

@@ -143,14 +143,12 @@ export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
const ctx = context as MSTeamsTurnContext;
// Handle file consent invokes before passing to normal flow
if (ctx.activity?.type === "invoke" && ctx.activity?.name === "fileConsent/invoke") {
// Send invoke response IMMEDIATELY to prevent Teams timeout
await ctx.sendActivity({ type: "invokeResponse", value: { status: 200 } });
// Handle file upload asynchronously (don't await)
handleFileConsentInvoke(ctx, deps.log).catch((err) => {
deps.log.debug?.("file consent handler error", { error: String(err) });
});
return;
const handled = await handleFileConsentInvoke(ctx, deps.log);
if (handled) {
// Send invoke response for file consent
await ctx.sendActivity({ type: "invokeResponse", value: { status: 200 } });
return;
}
}
return originalRun.call(handler, context);
};

View File

@@ -7,11 +7,8 @@ import {
resolveControlCommandGate,
resolveDefaultGroupPolicy,
isDangerousNameMatchingEnabled,
readStoreAllowFromForDmPolicy,
resolveMentionGating,
formatAllowlistMatchMeta,
resolveEffectiveAllowFromLists,
resolveDmGroupAccessWithLists,
type HistoryEntry,
} from "openclaw/plugin-sdk";
import {
@@ -130,30 +127,70 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const senderName = from.name ?? from.id;
const senderId = from.aadObjectId ?? from.id;
const dmPolicy = msteamsCfg?.dmPolicy ?? "pairing";
const storedAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "msteams",
dmPolicy,
readStore: (provider) => core.channel.pairing.readAllowFromStore(provider),
});
const storedAllowFrom =
dmPolicy === "allowlist"
? []
: await core.channel.pairing.readAllowFromStore("msteams").catch(() => []);
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
// Check DM policy for direct messages.
const dmAllowFrom = msteamsCfg?.allowFrom ?? [];
const configuredDmAllowFrom = dmAllowFrom.map((v) => String(v));
const groupAllowFrom = msteamsCfg?.groupAllowFrom;
const resolvedAllowFromLists = resolveEffectiveAllowFromLists({
allowFrom: configuredDmAllowFrom,
groupAllowFrom,
storeAllowFrom: storedAllowFrom,
dmPolicy,
});
const effectiveDmAllowFrom = [...configuredDmAllowFrom, ...storedAllowFrom];
if (isDirectMessage && msteamsCfg) {
const allowFrom = dmAllowFrom;
if (dmPolicy === "disabled") {
log.debug?.("dropping dm (dms disabled)");
return;
}
if (dmPolicy !== "open") {
const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom];
const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg);
const allowMatch = resolveMSTeamsAllowlistMatch({
allowFrom: effectiveAllowFrom,
senderId,
senderName,
allowNameMatching,
});
if (!allowMatch.allowed) {
if (dmPolicy === "pairing") {
const request = await core.channel.pairing.upsertPairingRequest({
channel: "msteams",
id: senderId,
meta: { name: senderName },
});
if (request) {
log.info("msteams pairing request created", {
sender: senderId,
label: senderName,
});
}
}
log.debug?.("dropping dm (not allowlisted)", {
sender: senderId,
label: senderName,
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
});
return;
}
}
}
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const groupPolicy =
!isDirectMessage && msteamsCfg
? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist")
: "disabled";
const effectiveGroupAllowFrom = resolvedAllowFromLists.effectiveGroupAllowFrom;
const groupAllowFrom =
!isDirectMessage && msteamsCfg
? (msteamsCfg.groupAllowFrom ??
(msteamsCfg.allowFrom && msteamsCfg.allowFrom.length > 0 ? msteamsCfg.allowFrom : []))
: [];
const effectiveGroupAllowFrom =
!isDirectMessage && msteamsCfg ? groupAllowFrom.map((v) => String(v)) : [];
const teamId = activity.channelData?.team?.id;
const teamName = activity.channelData?.team?.name;
const channelName = activity.channelData?.channel?.name;
@@ -164,61 +201,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
conversationId,
channelName,
});
const senderGroupPolicy =
groupPolicy === "disabled"
? "disabled"
: effectiveGroupAllowFrom.length > 0
? "allowlist"
: "open";
const access = resolveDmGroupAccessWithLists({
isGroup: !isDirectMessage,
dmPolicy,
groupPolicy: senderGroupPolicy,
allowFrom: configuredDmAllowFrom,
groupAllowFrom,
storeAllowFrom: storedAllowFrom,
groupAllowFromFallbackToAllowFrom: false,
isSenderAllowed: (allowFrom) =>
resolveMSTeamsAllowlistMatch({
allowFrom,
senderId,
senderName,
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
}).allowed,
});
const effectiveDmAllowFrom = access.effectiveAllowFrom;
if (isDirectMessage && msteamsCfg && access.decision !== "allow") {
if (access.reason === "dmPolicy=disabled") {
log.debug?.("dropping dm (dms disabled)");
return;
}
const allowMatch = resolveMSTeamsAllowlistMatch({
allowFrom: effectiveDmAllowFrom,
senderId,
senderName,
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
});
if (access.decision === "pairing") {
const request = await core.channel.pairing.upsertPairingRequest({
channel: "msteams",
id: senderId,
meta: { name: senderName },
});
if (request) {
log.info("msteams pairing request created", {
sender: senderId,
label: senderName,
});
}
}
log.debug?.("dropping dm (not allowlisted)", {
sender: senderId,
label: senderName,
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
});
return;
}
if (!isDirectMessage && msteamsCfg) {
if (groupPolicy === "disabled") {
@@ -245,12 +227,13 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
});
return;
}
if (effectiveGroupAllowFrom.length > 0 && access.decision !== "allow") {
if (effectiveGroupAllowFrom.length > 0) {
const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg);
const allowMatch = resolveMSTeamsAllowlistMatch({
allowFrom: effectiveGroupAllowFrom,
senderId,
senderName,
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
allowNameMatching,
});
if (!allowMatch.allowed) {
log.debug?.("dropping group message (not in groupAllowFrom)", {
@@ -550,20 +533,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
log.info("dispatching to agent", { sessionKey: route.sessionKey });
try {
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions,
}),
replyOptions,
});
markDispatchIdle();
log.info("dispatch complete", { queuedFinal, counts });
if (!queuedFinal) {

View File

@@ -4,8 +4,7 @@ import {
createReplyPrefixOptions,
formatTextWithAttachmentLinks,
logInboundDrop,
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithCommandGate,
resolveControlCommandGate,
resolveOutboundMediaUrls,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
@@ -97,11 +96,10 @@ export async function handleNextcloudTalkInbound(params: {
const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom);
const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom);
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: CHANNEL_ID,
dmPolicy,
readStore: (provider) => core.channel.pairing.readAllowFromStore(provider),
});
const storeAllowFrom =
dmPolicy === "allowlist"
? []
: await core.channel.pairing.readAllowFromStore(CHANNEL_ID).catch(() => []);
const storeAllowList = normalizeNextcloudTalkAllowlist(storeAllowFrom);
const roomMatch = resolveNextcloudTalkRoomMatch({
@@ -120,6 +118,11 @@ export async function handleNextcloudTalkInbound(params: {
}
const roomAllowFrom = normalizeNextcloudTalkAllowlist(roomConfig?.allowFrom);
const baseGroupAllowFrom =
configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom;
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean);
const effectiveGroupAllowFrom = [...baseGroupAllowFrom].filter(Boolean);
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg: config as OpenClawConfig,
@@ -127,33 +130,25 @@ export async function handleNextcloudTalkInbound(params: {
});
const useAccessGroups =
(config.commands as Record<string, unknown> | undefined)?.useAccessGroups !== false;
const senderAllowedForCommands = resolveNextcloudTalkAllowlistMatch({
allowFrom: isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
senderId,
}).allowed;
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
const access = resolveDmGroupAccessWithCommandGate({
isGroup,
dmPolicy,
groupPolicy,
allowFrom: configAllowFrom,
groupAllowFrom: configGroupAllowFrom,
storeAllowFrom: storeAllowList,
isSenderAllowed: (allowFrom) =>
resolveNextcloudTalkAllowlistMatch({
allowFrom,
senderId,
}).allowed,
command: {
useAccessGroups,
allowTextCommands,
hasControlCommand,
},
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{
configured: (isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0,
allowed: senderAllowedForCommands,
},
],
allowTextCommands,
hasControlCommand,
});
const commandAuthorized = access.commandAuthorized;
const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom;
const commandAuthorized = commandGate.commandAuthorized;
if (isGroup) {
if (access.decision !== "allow") {
runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (reason=${access.reason})`);
return;
}
const groupAllow = resolveNextcloudTalkGroupAllow({
groupPolicy,
outerAllowFrom: effectiveGroupAllowFrom,
@@ -165,36 +160,48 @@ export async function handleNextcloudTalkInbound(params: {
return;
}
} else {
if (access.decision !== "allow") {
if (access.decision === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: CHANNEL_ID,
id: senderId,
meta: { name: senderName || undefined },
});
if (created) {
try {
await sendMessageNextcloudTalk(
roomToken,
core.channel.pairing.buildPairingReply({
channel: CHANNEL_ID,
idLine: `Your Nextcloud user id: ${senderId}`,
code,
}),
{ accountId: account.accountId },
);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`);
if (dmPolicy === "disabled") {
runtime.log?.(`nextcloud-talk: drop DM sender=${senderId} (dmPolicy=disabled)`);
return;
}
if (dmPolicy !== "open") {
const dmAllowed = resolveNextcloudTalkAllowlistMatch({
allowFrom: effectiveAllowFrom,
senderId,
}).allowed;
if (!dmAllowed) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: CHANNEL_ID,
id: senderId,
meta: { name: senderName || undefined },
});
if (created) {
try {
await sendMessageNextcloudTalk(
roomToken,
core.channel.pairing.buildPairingReply({
channel: CHANNEL_ID,
idLine: `Your Nextcloud user id: ${senderId}`,
code,
}),
{ accountId: account.accountId },
);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(
`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`,
);
}
}
}
runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (dmPolicy=${dmPolicy})`);
return;
}
runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (reason=${access.reason})`);
return;
}
}
if (access.shouldBlockControlCommand) {
if (isGroup && commandGate.shouldBlock) {
logInboundDrop({
log: (message) => runtime.log?.(message),
channel: CHANNEL_ID,

View File

@@ -355,7 +355,6 @@ async function processMessageWithPipeline(params: {
isGroup,
dmPolicy,
configuredAllowFrom: configAllowFrom,
configuredGroupAllowFrom: groupAllowFrom,
senderId,
isSenderAllowed: isZaloSenderAllowed,
readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"),

View File

@@ -54,9 +54,8 @@
"build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts",
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:auth:no-pairing-store-group && pnpm check:host-env-policy:swift",
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
"deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-prune && pnpm deadcode:report:ci:ts-unused",
"deadcode:knip": "pnpm dlx knip --no-progress",
@@ -84,22 +83,18 @@
"gateway:dev": "OPENCLAW_SKIP_CHANNELS=1 CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway",
"gateway:dev:reset": "OPENCLAW_SKIP_CHANNELS=1 CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset",
"gateway:watch": "node scripts/watch-node.mjs gateway --force",
"gen:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --write",
"ghsa:patch": "node scripts/ghsa-patch.mjs",
"ios:build": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
"ios:gen": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate'",
"ios:open": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",
"ios:run": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'",
"lint": "oxlint --type-aware",
"lint:all": "pnpm lint && pnpm lint:swift",
"lint:auth:no-pairing-store-group": "node scripts/check-no-pairing-store-group-auth.mjs",
"lint:docs": "pnpm dlx markdownlint-cli2",
"lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix",
"lint:fix": "oxlint --type-aware --fix && pnpm format",
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
"lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs",
"lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs",
"lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs",
"lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",
"mac:open": "open dist/OpenClaw.app",
"mac:package": "bash scripts/package-mac-app.sh",
@@ -136,7 +131,6 @@
"test:install:smoke": "bash scripts/test-install-sh-docker.sh",
"test:live": "OPENCLAW_LIVE_TEST=1 CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts",
"test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=serial node scripts/test-parallel.mjs",
"test:sectriage": "pnpm exec vitest run --config vitest.gateway.config.ts && vitest run --config vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts",
"test:ui": "pnpm lint:ui:no-raw-window-open && pnpm --dir ui test",
"test:voicecall:closedloop": "vitest run extensions/voice-call/src/manager.test.ts extensions/voice-call/src/media-stream.test.ts src/plugins/voice-call.plugin.test.ts --maxWorkers=1",
"test:watch": "vitest",
@@ -178,7 +172,7 @@
"dotenv": "^17.3.1",
"express": "^5.2.1",
"file-type": "^21.3.0",
"grammy": "^1.40.1",
"grammy": "^1.40.0",
"https-proxy-agent": "^7.0.6",
"ipaddr.js": "^2.3.0",
"jiti": "^2.6.1",
@@ -208,7 +202,7 @@
"@lit/context": "^1.1.6",
"@types/express": "^5.0.6",
"@types/markdown-it": "^14.1.2",
"@types/node": "^25.3.1",
"@types/node": "^25.3.0",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260225.1",

198
pnpm-lock.yaml generated
View File

@@ -37,10 +37,10 @@ importers:
version: 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)
'@grammyjs/runner':
specifier: ^2.0.3
version: 2.0.3(grammy@1.40.1)
version: 2.0.3(grammy@1.40.0)
'@grammyjs/transformer-throttler':
specifier: ^1.2.1
version: 1.2.1(grammy@1.40.1)
version: 1.2.1(grammy@1.40.0)
'@homebridge/ciao':
specifier: ^1.3.5
version: 1.3.5
@@ -117,8 +117,8 @@ importers:
specifier: ^21.3.0
version: 21.3.0
grammy:
specifier: ^1.40.1
version: 1.40.1
specifier: ^1.40.0
version: 1.40.0
https-proxy-agent:
specifier: ^7.0.6
version: 7.0.6
@@ -205,8 +205,8 @@ importers:
specifier: ^14.1.2
version: 14.1.2
'@types/node':
specifier: ^25.3.1
version: 25.3.1
specifier: ^25.3.0
version: 25.3.0
'@types/qrcode-terminal':
specifier: ^0.12.2
version: 0.12.2
@@ -218,7 +218,7 @@ importers:
version: 7.0.0-dev.20260225.1
'@vitest/coverage-v8':
specifier: ^4.0.18
version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)
version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)
lit:
specifier: ^3.3.2
version: 3.3.2
@@ -245,7 +245,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.1)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
optionalDependencies:
'@discordjs/opus':
specifier: ^0.10.0
@@ -493,17 +493,17 @@ importers:
version: 0.21.1(signal-polyfill@0.2.2)
vite:
specifier: 7.3.1
version: 7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
devDependencies:
'@vitest/browser-playwright':
specifier: 4.0.18
version: 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
version: 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
playwright:
specifier: ^1.58.2
version: 1.58.2
vitest:
specifier: 4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.1)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
packages:
@@ -981,15 +981,6 @@ packages:
'@modelcontextprotocol/sdk':
optional: true
'@google/genai@1.43.0':
resolution: {integrity: sha512-hklCsJNdMlDM1IwcCVcGQFBg2izY0+t5BIGbRsxi2UnKi6AGKL7pqJqmBDNRbw0bYCs4y3NA7TB+fkKfP/Nrdw==}
engines: {node: '>=20.0.0'}
peerDependencies:
'@modelcontextprotocol/sdk': ^1.25.2
peerDependenciesMeta:
'@modelcontextprotocol/sdk':
optional: true
'@grammyjs/runner@2.0.3':
resolution: {integrity: sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A==}
engines: {node: '>=12.20.0 || >=14.13.1'}
@@ -2884,14 +2875,14 @@ packages:
'@types/node@10.17.60':
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
'@types/node@20.19.34':
resolution: {integrity: sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==}
'@types/node@20.19.33':
resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==}
'@types/node@24.10.14':
resolution: {integrity: sha512-OowOUbD1lBCOFIPOZ8xnMIhgqA4sCutMiYOmPHL1PTLt5+y1XA+g2+yC9OOyz8p+deMZqPZLxfMjYIfrKsPeFg==}
'@types/node@24.10.13':
resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==}
'@types/node@25.3.1':
resolution: {integrity: sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw==}
'@types/node@25.3.0':
resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==}
'@types/qrcode-terminal@0.12.2':
resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==}
@@ -3060,8 +3051,8 @@ packages:
link-preview-js:
optional: true
'@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67}
'@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
resolution: {commit: 1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67, repo: https://github.com/whiskeysockets/libsignal-node.git, type: git}
version: 2.0.1
abbrev@1.1.1:
@@ -3924,8 +3915,8 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
grammy@1.40.1:
resolution: {integrity: sha512-bTe8SWXD8/Sdt2LGAAAsFGhuxI9RG8zL2gGk3V42A/RxriPqBQqwMGoNSldNK1qIFD2EaVuq7NQM8+ZAmNgHLw==}
grammy@1.40.0:
resolution: {integrity: sha512-ssuE7fc1AwqlUxHr931OCVW3fU+oFDjHZGgvIedPKXfTdjXvzP19xifvVGCnPtYVUig1Kz+gwxe4A9M5WdkT4Q==}
engines: {node: ^12.20.0 || >=14.13.1}
has-flag@4.0.0:
@@ -5211,8 +5202,8 @@ packages:
peerDependencies:
signal-polyfill: ^0.2.0
simple-git@3.32.3:
resolution: {integrity: sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==}
simple-git@3.32.2:
resolution: {integrity: sha512-n/jhNmvYh8dwyfR6idSfpXrFazuyd57jwNMzgjGnKZV/1lTh0HKvPq20v4AQ62rP+l19bWjjXPTCdGHMt0AdrQ==}
simple-yenc@1.0.4:
resolution: {integrity: sha512-5gvxpSd79e9a3V4QDYUqnqxeD4HGlhCakVpb6gMnDD7lexJggSBJRBO5h52y/iJrdXRilX9UCuDaIJhSWm5OWw==}
@@ -5357,10 +5348,6 @@ packages:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
strip-ansi@7.2.0:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
@@ -6292,7 +6279,7 @@ snapshots:
'@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.1.1)':
dependencies:
'@types/node': 25.3.1
'@types/node': 25.3.0
discord-api-types: 0.38.37
optionalDependencies:
'@cloudflare/workers-types': 4.20260120.0
@@ -6569,26 +6556,15 @@ snapshots:
- supports-color
- utf-8-validate
'@google/genai@1.43.0':
dependencies:
google-auth-library: 10.6.1
p-retry: 4.6.2
protobufjs: 7.5.4
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
'@grammyjs/runner@2.0.3(grammy@1.40.1)':
'@grammyjs/runner@2.0.3(grammy@1.40.0)':
dependencies:
abort-controller: 3.0.0
grammy: 1.40.1
grammy: 1.40.0
'@grammyjs/transformer-throttler@1.2.1(grammy@1.40.1)':
'@grammyjs/transformer-throttler@1.2.1(grammy@1.40.0)':
dependencies:
bottleneck: 2.19.5
grammy: 1.40.1
grammy: 1.40.0
'@grammyjs/types@3.24.0': {}
@@ -6817,7 +6793,7 @@ snapshots:
'@line/bot-sdk@10.6.0':
dependencies:
'@types/node': 24.10.14
'@types/node': 24.10.13
optionalDependencies:
axios: 1.13.5(debug@4.4.3)
transitivePeerDependencies:
@@ -6966,7 +6942,7 @@ snapshots:
dependencies:
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
'@aws-sdk/client-bedrock-runtime': 3.998.0
'@google/genai': 1.43.0
'@google/genai': 1.42.0
'@mistralai/mistralai': 1.10.0
'@sinclair/typebox': 0.34.48
ajv: 8.18.0
@@ -7951,14 +7927,14 @@ snapshots:
'@slack/logger@4.0.0':
dependencies:
'@types/node': 25.3.1
'@types/node': 25.3.0
'@slack/oauth@3.0.4':
dependencies:
'@slack/logger': 4.0.0
'@slack/web-api': 7.14.1
'@types/jsonwebtoken': 9.0.10
'@types/node': 25.3.1
'@types/node': 25.3.0
jsonwebtoken: 9.0.3
transitivePeerDependencies:
- debug
@@ -7967,7 +7943,7 @@ snapshots:
dependencies:
'@slack/logger': 4.0.0
'@slack/web-api': 7.14.1
'@types/node': 25.3.1
'@types/node': 25.3.0
'@types/ws': 8.18.1
eventemitter3: 5.0.4
ws: 8.19.0
@@ -7982,7 +7958,7 @@ snapshots:
dependencies:
'@slack/logger': 4.0.0
'@slack/types': 2.20.0
'@types/node': 25.3.1
'@types/node': 25.3.0
'@types/retry': 0.12.0
axios: 1.13.5(debug@4.4.3)
eventemitter3: 5.0.4
@@ -8448,7 +8424,7 @@ snapshots:
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 25.3.1
'@types/node': 25.3.0
'@types/bun@1.3.9':
dependencies:
@@ -8468,7 +8444,7 @@ snapshots:
'@types/connect@3.4.38':
dependencies:
'@types/node': 25.3.1
'@types/node': 25.3.0
'@types/deep-eql@4.0.2': {}
@@ -8476,14 +8452,14 @@ snapshots:
'@types/express-serve-static-core@4.19.8':
dependencies:
'@types/node': 25.3.1
'@types/node': 25.3.0
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
'@types/express-serve-static-core@5.1.1':
dependencies:
'@types/node': 25.3.1
'@types/node': 25.3.0
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
@@ -8508,7 +8484,7 @@ snapshots:
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 25.3.1
'@types/node': 25.3.0
'@types/linkify-it@5.0.0': {}
@@ -8529,15 +8505,15 @@ snapshots:
'@types/node@10.17.60': {}
'@types/node@20.19.34':
'@types/node@20.19.33':
dependencies:
undici-types: 6.21.0
'@types/node@24.10.14':
'@types/node@24.10.13':
dependencies:
undici-types: 7.16.0
'@types/node@25.3.1':
'@types/node@25.3.0':
dependencies:
undici-types: 7.18.2
@@ -8550,7 +8526,7 @@ snapshots:
'@types/request@2.48.13':
dependencies:
'@types/caseless': 0.12.5
'@types/node': 25.3.1
'@types/node': 25.3.0
'@types/tough-cookie': 4.0.5
form-data: 2.5.4
@@ -8559,22 +8535,22 @@ snapshots:
'@types/send@0.17.6':
dependencies:
'@types/mime': 1.3.5
'@types/node': 25.3.1
'@types/node': 25.3.0
'@types/send@1.2.1':
dependencies:
'@types/node': 25.3.1
'@types/node': 25.3.0
'@types/serve-static@1.15.10':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 25.3.1
'@types/node': 25.3.0
'@types/send': 0.17.6
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 25.3.1
'@types/node': 25.3.0
'@types/tough-cookie@4.0.5': {}
@@ -8582,11 +8558,11 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.3.1
'@types/node': 25.3.0
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 25.3.1
'@types/node': 25.3.0
optional: true
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260225.1':
@@ -8655,29 +8631,29 @@ snapshots:
- '@cypress/request'
- supports-color
'@vitest/browser-playwright@4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)':
'@vitest/browser-playwright@4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)':
dependencies:
'@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
playwright: 1.58.2
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.1)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)':
'@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)':
dependencies:
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/utils': 4.0.18
magic-string: 0.30.21
pixelmatch: 7.1.0
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.1)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
@@ -8685,7 +8661,7 @@ snapshots:
- utf-8-validate
- vite
'@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)':
'@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.18
@@ -8697,9 +8673,9 @@ snapshots:
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.1)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
optionalDependencies:
'@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/expect@4.0.18':
dependencies:
@@ -8710,13 +8686,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
'@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
'@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.18
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/pretty-format@4.0.18':
dependencies:
@@ -8768,7 +8744,7 @@ snapshots:
'@cacheable/node-cache': 1.7.6
'@hapi/boom': 9.1.4
async-mutex: 0.5.0
libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67'
libsignal: '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67'
lru-cache: 11.2.6
music-metadata: 11.12.1
p-queue: 9.1.0
@@ -8783,7 +8759,7 @@ snapshots:
- supports-color
- utf-8-validate
'@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
'@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
dependencies:
curve25519-js: 0.0.4
protobufjs: 6.8.8
@@ -8864,7 +8840,7 @@ snapshots:
'@swc/helpers': 0.5.19
'@types/command-line-args': 5.2.3
'@types/command-line-usage': 5.0.4
'@types/node': 20.19.34
'@types/node': 20.19.33
command-line-args: 5.2.1
command-line-usage: 7.0.3
flatbuffers: 24.12.23
@@ -9035,7 +9011,7 @@ snapshots:
bun-types@1.3.9:
dependencies:
'@types/node': 25.3.1
'@types/node': 25.3.0
optional: true
bytes@3.1.2: {}
@@ -9729,7 +9705,7 @@ snapshots:
graceful-fs@4.2.11: {}
grammy@1.40.1:
grammy@1.40.0:
dependencies:
'@grammyjs/types': 3.24.0
abort-controller: 3.0.0
@@ -9900,7 +9876,7 @@ snapshots:
sleep-promise: 9.1.0
slice-ansi: 7.1.2
stdout-update: 4.0.1
strip-ansi: 7.2.0
strip-ansi: 7.1.2
optionalDependencies:
'@reflink/reflink': 0.1.19
@@ -10408,10 +10384,10 @@ snapshots:
pretty-ms: 9.3.0
proper-lockfile: 4.1.2
semver: 7.7.4
simple-git: 3.32.3
simple-git: 3.32.2
slice-ansi: 7.1.2
stdout-update: 4.0.1
strip-ansi: 7.2.0
strip-ansi: 7.1.2
validate-npm-package-name: 6.0.2
which: 5.0.0
yargs: 17.7.2
@@ -10544,8 +10520,8 @@ snapshots:
'@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.1.1)
'@clack/prompts': 1.0.1
'@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)
'@grammyjs/runner': 2.0.3(grammy@1.40.1)
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.40.1)
'@grammyjs/runner': 2.0.3(grammy@1.40.0)
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.40.0)
'@homebridge/ciao': 1.3.5
'@larksuiteoapi/node-sdk': 1.59.0
'@line/bot-sdk': 10.6.0
@@ -10571,7 +10547,7 @@ snapshots:
dotenv: 17.3.1
express: 5.2.1
file-type: 21.3.0
grammy: 1.40.1
grammy: 1.40.0
https-proxy-agent: 7.0.6
ipaddr.js: 2.3.0
jiti: 2.6.1
@@ -10631,7 +10607,7 @@ snapshots:
log-symbols: 6.0.0
stdin-discarder: 0.2.2
string-width: 7.2.0
strip-ansi: 7.2.0
strip-ansi: 7.1.2
osc-progress@0.3.0: {}
@@ -10892,7 +10868,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 25.3.1
'@types/node': 25.3.0
long: 5.3.2
protobufjs@8.0.0:
@@ -10907,7 +10883,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 25.3.1
'@types/node': 25.3.0
long: 5.3.2
proxy-addr@2.0.7:
@@ -11286,7 +11262,7 @@ snapshots:
dependencies:
signal-polyfill: 0.2.2
simple-git@3.32.3:
simple-git@3.32.2:
dependencies:
'@kwsites/file-exists': 1.1.1
'@kwsites/promise-deferred': 1.1.1
@@ -11398,7 +11374,7 @@ snapshots:
ansi-escapes: 6.2.1
ansi-styles: 6.2.3
string-width: 7.2.0
strip-ansi: 7.2.0
strip-ansi: 7.1.2
stealthy-require@1.1.1: {}
@@ -11433,7 +11409,7 @@ snapshots:
dependencies:
emoji-regex: 10.6.0
get-east-asian-width: 1.5.0
strip-ansi: 7.2.0
strip-ansi: 7.1.2
string_decoder@1.1.1:
dependencies:
@@ -11451,10 +11427,6 @@ snapshots:
dependencies:
ansi-regex: 6.2.2
strip-ansi@7.2.0:
dependencies:
ansi-regex: 6.2.2
strip-json-comments@2.0.1: {}
strnum@2.1.2: {}
@@ -11666,7 +11638,7 @@ snapshots:
core-util-is: 1.0.2
extsprintf: 1.3.0
vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2):
vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3)
@@ -11675,17 +11647,17 @@ snapshots:
rollup: 4.59.0
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 25.3.1
'@types/node': 25.3.0
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.30.2
tsx: 4.21.0
yaml: 2.8.2
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.1)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2):
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.18
'@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18
@@ -11702,12 +11674,12 @@ snapshots:
tinyexec: 1.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 25.3.1
'@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
'@types/node': 25.3.0
'@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
transitivePeerDependencies:
- jiti
- less

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