Compare commits

...

48 Commits

Author SHA1 Message Date
Peter Steinberger f9b1079283 build: cut 2026.3.28 stable
Docker Release / validate_manual_backfill (push) Has been cancelled
Docker Release / approve_manual_backfill (push) Has been cancelled
Docker Release / build-amd64 (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
Docker Release / create-manifest (push) Has been cancelled
2026-03-29 02:33:41 +01:00
Peter Steinberger 584e627d77 docs: add changelog for CJK memory chunking (#40271) 2026-03-29 10:22:43 +09:00
AaronLuo00 f8547fcae4 fix: guard fine-split against breaking UTF-16 surrogate pairs
When re-splitting CJK-heavy segments at chunking.tokens, check whether the
slice boundary falls on a high surrogate (0xD800–0xDBFF) and if so extend
by one code unit to keep the pair intact.  Prevents producing broken
surrogate halves for CJK Extension B+ characters (U+20000+).

Add test verifying no lone surrogates appear when splitting lines of
surrogate-pair characters with an odd token budget.

Addresses third-round Codex P2 review comment.
2026-03-29 10:22:43 +09:00
AaronLuo00 3b95aa8804 fix: address second-round review — Latin backward compat and emoji consistency
- Two-pass line splitting: first slice at maxChars (unchanged for Latin),
  then re-split only CJK-heavy segments at chunking.tokens. This preserves
  the original ~800-char segments for ASCII lines while keeping CJK chunks
  within the token budget.

- Narrow surrogate-pair adjustment to CJK Extension B+ range (D840–D87E)
  only, so emoji surrogate pairs are not affected. Mixed CJK+emoji text
  is now handled consistently regardless of composition.

- Add tests: emoji handling (2), Latin backward-compat long-line (1).

Addresses Codex P1 (oversized CJK segments) and P2s (Latin over-splitting,
emoji surrogate inconsistency).
2026-03-29 10:22:43 +09:00
AaronLuo00 a5147d4d88 fix: address bot review — surrogate-pair counting and CJK line splitting
- Use code-point length instead of UTF-16 length in estimateStringChars()
  so that CJK Extension B+ surrogate pairs (U+20000+) are counted as 1
  character, not 2 (fixes ~25% overestimate for rare characters).

- Change long-line split step from maxChars to chunking.tokens so that
  CJK lines are sliced into token-budget-sized segments instead of
  char-budget-sized segments that produce ~4x oversized chunks.

- Add tests for both fixes: surrogate-pair handling and long CJK line
  splitting.

Addresses review feedback from Greptile and Codex bots.
2026-03-29 10:22:43 +09:00
AaronLuo00 971ecabe80 fix(memory): account for CJK characters in QMD memory chunking
The QMD memory system uses a fixed 4:1 chars-to-tokens ratio for chunk
sizing, which severely underestimates CJK (Chinese/Japanese/Korean) text
where each character is roughly 1 token. This causes oversized chunks for
CJK users, degrading vector search quality and wasting context window space.

Changes:
- Add shared src/utils/cjk-chars.ts module with CJK-aware character
  counting (estimateStringChars) and token estimation helpers
- Update chunkMarkdown() in src/memory/internal.ts to use weighted
  character lengths for chunk boundary decisions and overlap calculation
- Replace hardcoded estimateTokensFromChars in the context report
  command with the shared utility
- Add 13 unit tests for the CJK estimation module and 5 new tests for
  CJK-aware memory chunking behavior

Backward compatible: pure ASCII/Latin text behavior is unchanged.

Closes #39965
Related: #40216
2026-03-29 10:22:43 +09:00
Vignesh Natarajan 7f46b03de0 fix: keep memory flush daily files append-only (#53725) (thanks @HPluseven) 2026-03-28 18:22:11 -07:00
Vignesh Natarajan 9d1498b2c2 Agents: add memory flush append regression 2026-03-28 18:22:11 -07:00
HPluseven 60b7613156 Agents: forward memory flush append guard 2026-03-28 18:22:11 -07:00
Vignesh Natarajan e2d0b7c583 chore(test): harden mattermost slash-http module mocks 2026-03-28 18:21:19 -07:00
Peter Steinberger 72de33c976 chore: refresh plugin sdk api baseline 2026-03-29 02:16:37 +01:00
Peter Steinberger 148a65fe90 refactor: share webhook channel status helpers 2026-03-29 02:11:22 +01:00
Gustavo Madeira Santana 2afc655bd5 ACP: document Matrix bind-here support 2026-03-28 21:07:58 -04:00
Vignesh Natarajan 19e52a1ba2 fix(memory/qmd): honor embedInterval independent of update interval 2026-03-28 18:05:05 -07:00
karesansui acbdafc4f4 fix: propagate webhook mode to health monitor snapshot
Webhook channels (LINE, Zalo, Nextcloud Talk, BlueBubbles) are
incorrectly flagged as stale-socket during quiet periods because
snapshot.mode is always undefined, making the mode !== "webhook"
guard in evaluateChannelHealth dead code.

Add mode: "webhook" to each webhook plugin's describeAccount and
propagate described.mode in getRuntimeSnapshot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 02:01:25 +01:00
Peter Steinberger acca306665 fix: polish LINE status snapshot checks (#45701) (thanks @tamaosamu) 2026-03-29 00:57:04 +00:00
劉超 03941e2dbf fix(line): use configured field in collectStatusIssues instead of raw token
collectStatusIssues previously checked account.channelAccessToken directly,
but this field is stripped by projectSafeChannelAccountSnapshotFields for
security. This caused 'openclaw status' to always report WARN even when the
token is valid and the LINE provider starts successfully.

Use account.configured instead, which is already computed by
buildChannelAccountSnapshot and correctly reflects whether credentials
are present. This is consistent with how other channels (e.g. Telegram)
implement their status checks.

Fixes #45693
2026-03-29 00:57:04 +00:00
Peter Steinberger b23ed7530b chore: ignore local tmp workspace 2026-03-29 00:51:03 +00:00
Peter Steinberger 5ebccf5e30 test: harden zalo webhook lifecycle tests 2026-03-29 00:48:02 +00:00
Peter Steinberger 9e1b524a00 fix: break mattermost runtime cycle 2026-03-29 00:43:58 +00:00
Peter Steinberger fcc9fd1623 fix: land LINE timing-safe signature validation (#55663) (thanks @gavyngong) 2026-03-29 00:43:17 +00:00
gavyngong 7626d18c64 fix(line): eliminate timing side-channel in HMAC signature validation
Pad both buffers to equal length before constant-time comparison.

Key fix: call timingSafeEqual unconditionally and store the result
before the && length check, ensuring the constant-time comparison
always runs regardless of buffer lengths. This avoids JavaScript's
&& short-circuit evaluation which would skip timingSafeEqual on
length mismatches, preserving the timing side-channel.

Changes:
- Pad hash and signature buffers to maxLen before comparison
- Store timingSafeEqual result before combining with length check
- Add explanatory comment about the short-circuit avoidance
2026-03-29 00:43:17 +00:00
Peter Steinberger 92fb0caf35 fix: harden mac gateway attach smoke 2026-03-29 00:35:40 +00:00
Peter Steinberger f9281d1b9d build: bump aws sdk and tsdown 2026-03-29 00:35:15 +00:00
Gustavo Madeira Santana 225d58b74a test: ignore stale isolated state dir in live env staging 2026-03-28 20:35:07 -04:00
Peter Steinberger 0e0945c5ed docs: reorder unreleased changelog fixes 2026-03-29 00:29:21 +00:00
Peter Steinberger 5cfb979766 docs: add missing CLAUDE symlinks 2026-03-29 09:29:04 +09:00
Peter Steinberger 5efed49208 fix: keep mac local gateway attached 2026-03-29 00:28:32 +00:00
Peter Steinberger c9f1506d2f docs(xai): clarify x_search onboarding flow 2026-03-29 00:25:18 +00:00
Harold Hunt fcee6fa047 Docs: add boundary AGENTS guides (#56647) 2026-03-28 20:22:03 -04:00
Peter Steinberger 03826b8075 fix(test): harden planner artifact cleanup and profile env fallback 2026-03-29 00:20:19 +00:00
Vignesh Natarajan c3a0304f63 chore(test): fix stale web search audit coverage 2026-03-28 17:18:57 -07:00
Peter Steinberger 3d69ad8308 fix: preserve Teams Entra JWT fallback on legacy validator errors 2026-03-29 09:15:13 +09:00
Peter Steinberger 5872f860c9 feat(xai): add plugin-owned x_search onboarding 2026-03-29 00:12:37 +00:00
Gustavo Madeira Santana ebb4794952 Tests: reuse paired provider contract aliases 2026-03-28 20:08:38 -04:00
Gustavo Madeira Santana 680c30bc5d Tests: shim config runtime for capability contracts 2026-03-28 20:02:28 -04:00
Peter Steinberger 3cbd3960f9 test: add minimax parallels smoke lane 2026-03-29 00:01:59 +00:00
Gustavo Madeira Santana d0e0150129 Tests: retry scoped contract registry loads 2026-03-28 19:53:21 -04:00
Gustavo Madeira Santana d3673fd53e Tests: isolate xAI contract lanes 2026-03-28 19:36:53 -04:00
Vignesh Natarajan 4e74e7e26c fix(memory): resolve slugified qmd search paths (#50313) 2026-03-28 16:26:38 -07:00
Gustavo Madeira Santana 5289e8f0fe Tests: lazy-load web search contract registries 2026-03-28 19:24:38 -04:00
Robin Waslander 3847ace25b fix(telegram): preserve forum topic routing for /new and /reset (#56654)
Build a topic-qualified routing target (telegram:<chatId>:topic:<threadId>)
for native commands in forum groups so /new and /reset stay scoped to
the active topic instead of falling back to General.

General topic (threadId=1) correctly falls through to the base chat
target since Telegram rejects message_thread_id=1 on sends.

Add regression tests for topic routing and General topic edge case.

Fixes #35963
2026-03-29 00:21:41 +01:00
Gustavo Madeira Santana 094524a549 Types: tighten Teams validator return type 2026-03-28 19:15:27 -04:00
Gustavo Madeira Santana bd1c48e4d9 Tests: lazy-load extension contract registries 2026-03-28 19:09:49 -04:00
Brad Groux dc382b09be fix(msteams): accept strict Bot Framework and Entra service tokens (#56631)
* msteams: log policy-based inbound drops at info level

* fix(msteams): validate Bot Framework and Entra service token issuers

---------

Co-authored-by: Brad Groux <bradgroux@users.noreply.github.com>
2026-03-28 18:04:00 -05:00
Gustavo Madeira Santana 0b8bc0e1b4 Tests: cap CI extension batch concurrency 2026-03-28 18:58:47 -04:00
frischeDaten 81432d6b7e fix(matrix): encrypt thumbnails in E2EE rooms using thumbnail_file (#54711)
Merged via squash.

Prepared head SHA: 92be0e1ac291ab988dc4da11e12768a6a2423067
Co-authored-by: frischeDaten <5878058+frischeDaten@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-28 18:41:23 -04:00
Robin Waslander 468185d1b5 fix(agents): handle unhandled stop reasons gracefully instead of crashing (#56639)
Wrap the embedded agent stream to catch 'Unhandled stop reason: ...'
errors from the provider adapter and convert them into structured
assistant error messages instead of crashing the agent run.

Covers all unknown stop reasons so future provider additions don't
crash the runner. The wrapper becomes a harmless no-op once the
upstream dependency handles them natively.

Fixes #43607
2026-03-28 23:35:12 +01:00
213 changed files with 4698 additions and 1477 deletions
+3
View File
@@ -137,3 +137,6 @@ docs/superpowers
# Deprecated changelog fragment workflow
changelog/fragments/
# Local scratch workspace
.tmp/
+42
View File
@@ -21,6 +21,48 @@
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
- When adding channels/extensions/apps/docs, update `.github/labeler.yml` and create matching GitHub labels (use existing channel/extension label colors).
## Architecture Boundaries
- Start here for the repo map:
- `extensions/*` = bundled plugins and the closest example surface for third-party plugins
- `src/plugin-sdk/*` = the public plugin contract that extensions are allowed to import
- `src/channels/*` = core channel implementation details behind the plugin/channel boundary
- `src/plugins/*` = plugin discovery, manifest validation, loader, registry, and contract enforcement
- `src/gateway/protocol/*` = typed Gateway control-plane and node wire protocol
- Progressive disclosure lives in local boundary guides:
- `extensions/AGENTS.md`
- `src/plugin-sdk/AGENTS.md`
- `src/channels/AGENTS.md`
- `src/plugins/AGENTS.md`
- `src/gateway/protocol/AGENTS.md`
- Plugin and extension boundary:
- Public docs: `docs/plugins/building-plugins.md`, `docs/plugins/architecture.md`, `docs/plugins/sdk-overview.md`, `docs/plugins/sdk-entrypoints.md`, `docs/plugins/sdk-runtime.md`, `docs/plugins/manifest.md`, `docs/plugins/sdk-channel-plugins.md`, `docs/plugins/sdk-provider-plugins.md`
- Definition files: `src/plugin-sdk/plugin-entry.ts`, `src/plugin-sdk/core.ts`, `src/plugin-sdk/provider-entry.ts`, `src/plugin-sdk/channel-contract.ts`, `scripts/lib/plugin-sdk-entrypoints.json`, `package.json`
- Rule: extensions must cross into core only through `openclaw/plugin-sdk/*`, manifest metadata, and documented runtime helpers. Do not import `src/**` from extension production code.
- Rule: core code and tests must not deep-import bundled plugin internals such as `extensions/<id>/src/**` or `extensions/<id>/onboard.js`. If core needs a bundled plugin helper, expose it through `extensions/<id>/api.ts` and, when it is a real cross-package contract, through `src/plugin-sdk/<id>.ts`.
- Compatibility: new plugin seams are allowed, but they must be added as documented, backwards-compatible, versioned contracts. We have third-party plugins in the wild and do not break them casually.
- Channel boundary:
- Public docs: `docs/plugins/sdk-channel-plugins.md`, `docs/plugins/architecture.md`
- Definition files: `src/channels/plugins/types.plugin.ts`, `src/channels/plugins/types.core.ts`, `src/channels/plugins/types.adapters.ts`, `src/plugin-sdk/core.ts`, `src/plugin-sdk/channel-contract.ts`
- Rule: `src/channels/**` is core implementation. If plugin authors need a new seam, add it to the Plugin SDK instead of telling them to import channel internals.
- Provider/model boundary:
- Public docs: `docs/plugins/sdk-provider-plugins.md`, `docs/concepts/model-providers.md`, `docs/plugins/architecture.md`
- Definition files: `src/plugins/types.ts`, `src/plugin-sdk/provider-entry.ts`, `src/plugin-sdk/provider-auth.ts`, `src/plugin-sdk/provider-catalog-shared.ts`, `src/plugin-sdk/provider-model-shared.ts`
- Rule: core owns the generic inference loop; provider plugins own provider-specific behavior through registration and typed hooks. Do not solve provider needs by reaching into unrelated core internals.
- Rule: avoid ad hoc reads of `plugins.entries.<id>.config` from unrelated core code. If core needs plugin-owned auth/config behavior, add or use a generic seam (`resolveSyntheticAuth`, public SDK/helper facades, manifest metadata, plugin auto-enable hooks) and honor plugin disablement plus SecretRef semantics.
- Rule: vendor-owned tools and settings belong in the owning plugin. Do not add provider-specific tool config, secret collection, or runtime enablement to core `tools.*` surfaces unless the tool is intentionally core-owned.
- Gateway protocol boundary:
- Public docs: `docs/gateway/protocol.md`, `docs/gateway/bridge-protocol.md`, `docs/concepts/architecture.md`
- Definition files: `src/gateway/protocol/schema.ts`, `src/gateway/protocol/schema/*.ts`, `src/gateway/protocol/index.ts`
- Rule: protocol changes are contract changes. Prefer additive evolution; incompatible changes require explicit versioning, docs, and client/codegen follow-through.
- Bundled plugin contract boundary:
- Public docs: `docs/plugins/architecture.md`, `docs/plugins/manifest.md`, `docs/plugins/sdk-overview.md`
- Definition files: `src/plugins/contracts/registry.ts`, `src/plugins/types.ts`, `src/extensions/public-artifacts.ts`
- Rule: keep manifest metadata, runtime registration, public SDK exports, and contract tests aligned. Do not create a hidden path around the declared plugin interfaces.
- Extension test boundary:
- Keep extension-owned onboarding/config/provider coverage under `extensions/<id>/**` when feasible.
- If core tests need bundled plugin behavior, consume it through public `src/plugin-sdk/<id>.ts` facades or `extensions/<id>/api.ts`, not private extension modules.
## Docs Linking (Mintlify)
- Docs are hosted on Mintlify (docs.openclaw.ai).
+16 -1
View File
@@ -4,7 +4,18 @@ Docs: https://docs.openclaw.ai
## Unreleased
## 2026.3.28-beta.1
### Fixes
- macOS/local gateway: stop OpenClaw.app from killing healthy local gateway listeners after startup by recognizing the current `openclaw-gateway` process title and using the current `openclaw gateway` launch shape.
- Memory/QMD: resolve slugified `memory_search` file hints back to the indexed filesystem path before returning search hits, so `memory_get` works again for mixed-case and spaced paths. (#50313) Thanks @erra9x.
- Memory/QMD: weight CJK-heavy text correctly when estimating chunk sizes, preserve surrogate-pair characters during fine splits, and keep long Latin lines on the old chunk boundaries so memory indexing produces better-sized chunks for CJK notes. (#40271) Thanks @AaronLuo00.
- Security/LINE: make webhook signature validation run the timing-safe compare even when the supplied signature length is wrong, closing a small timing side-channel. (#55663) Thanks @gavyngong.
- LINE/status: stop `openclaw status` from warning about missing credentials when sanitized LINE snapshots are already configured, while still surfacing whether the missing field is the token or secret. (#45701) Thanks @tamaosamu.
- Gateway/health: carry webhook-vs-polling account mode from channel descriptors into runtime snapshots so passive channels like LINE and BlueBubbles skip false stale-socket health failures. (#47488) Thanks @karesansui-u.
- Memory/QMD: honor `memory.qmd.update.embedInterval` even when regular QMD update cadence is disabled or slower by arming a dedicated embed-cadence maintenance timer, while avoiding redundant timers when regular updates are already frequent enough. (#37326) Thanks @barronlroth.
- Agents/memory flush: keep daily memory flush files append-only during embedded attempts so compaction writes do not overwrite earlier notes. (#53725) Thanks @HPluseven.
## 2026.3.28
### Breaking
@@ -14,6 +25,7 @@ Docs: https://docs.openclaw.ai
### Changes
- xAI/tools: move the bundled xAI provider to the Responses API, add first-class `x_search`, and auto-enable the xAI plugin from owned web-search and tool config so bundled Grok auth/configured search flows work without manual plugin toggles. (#56048) Thanks @huntharo.
- xAI/onboarding: let the bundled Grok web-search plugin offer optional `x_search` setup during `openclaw onboard` and `openclaw configure --section web`, including an x_search model picker with the shared xAI key.
- MiniMax: add image generation provider for `image-01` model, supporting generate and image-to-image editing with aspect ratio control. (#54487) Thanks @liyuan97.
- Plugins/hooks: add async `requireApproval` to `before_tool_call` hooks, letting plugins pause tool execution and prompt the user for approval via the exec approval overlay, Telegram buttons, Discord interactions, or the `/approve` command on any channel. The `/approve` command now handles both exec and plugin approvals with automatic fallback. (#55339) Thanks @vaclavbelak and @joshavant.
- ACP/channels: add current-conversation ACP binds for Discord, BlueBubbles, and iMessage so `/acp spawn codex --bind here` can turn the current chat into a Codex-backed workspace without creating a child thread, and document the distinction between chat surface, ACP session, and runtime workspace.
@@ -36,6 +48,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/Anthropic: recover unhandled provider stop reasons (e.g. `sensitive`) as structured assistant errors instead of crashing the agent run. (#56639)
- Google/models: resolve Gemini 3.1 pro, flash, and flash-lite for all Google provider aliases by passing the actual runtime provider ID and adding a template-provider fallback; fix flash-lite prefix ordering. (#56567)
- OpenAI Codex/image tools: register Codex for media understanding and route image prompts through Codex instructions so image analysis no longer fails on missing provider registration or missing `instructions`. (#54829) Thanks @neeravmakwana.
- Agents/image tool: restore the generic image-runtime fallback when no provider-specific media-understanding provider is registered, so image analysis works again for providers like `openrouter` and `minimax-portal`. (#54858) Thanks @MonkeyLeeT.
@@ -130,6 +143,8 @@ Docs: https://docs.openclaw.ai
- Agents/failover: classify HTTP 410 errors as retryable timeouts by default while still preserving explicit session-expired, billing, and auth signals from the payload. (#55201) thanks @nikus-pan.
- Agents/subagents: restore completion announce delivery for extension channels like BlueBubbles. (#56348)
- Plugins/Matrix: load bundled `@matrix-org/matrix-sdk-crypto-nodejs` through `createRequire(...)` so E2EE media send and receive keep the package-local native binding lookup working in packaged ESM builds. (#54566) thanks @joelnishanth.
- Plugins/Matrix: encrypt E2EE image thumbnails with `thumbnail_file` while keeping unencrypted-room previews on `thumbnail_url`, so encrypted Matrix image events keep thumbnail metadata without leaking plaintext previews. (#54711) thanks @frischeDaten.
- Telegram/forum topics: keep native `/new` and `/reset` routed to the active topic by preserving the topic target on forum-thread command context. (#35963)
## 2026.3.24
+2 -2
View File
@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026032801
versionName = "2026.3.28-beta.1"
versionCode = 2026032800
versionName = "2026.3.28"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
+2 -2
View File
@@ -1,8 +1,8 @@
// Shared iOS version defaults.
// Generated overrides live in build/Version.xcconfig (git-ignored).
OPENCLAW_GATEWAY_VERSION = 2026.3.28-beta.1
OPENCLAW_GATEWAY_VERSION = 2026.3.28
OPENCLAW_MARKETING_VERSION = 2026.3.28
OPENCLAW_BUILD_VERSION = 2026032801
OPENCLAW_BUILD_VERSION = 2026032800
#include? "../build/Version.xcconfig"
@@ -193,7 +193,7 @@ enum GatewayEnvironment {
let port = self.gatewayPort()
if let gatewayBin {
let bind = self.preferredGatewayBind() ?? "loopback"
let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
let cmd = [gatewayBin, "gateway", "--port", "\(port)", "--bind", bind]
return GatewayCommandResolution(status: status, command: cmd)
}
@@ -201,7 +201,7 @@ enum GatewayEnvironment {
case let .success(resolvedRuntime) = runtime
{
let bind = self.preferredGatewayBind() ?? "loopback"
let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind]
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)", "--bind", bind]
return GatewayCommandResolution(status: status, command: cmd)
}
@@ -44,6 +44,7 @@ final class GatewayProcessManager {
private var logRefreshTask: Task<Void, Never>?
#if DEBUG
private var testingConnection: GatewayConnection?
private var testingSkipControlChannelRefresh = false
#endif
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway.process")
@@ -364,6 +365,11 @@ final class GatewayProcessManager {
}
private func refreshControlChannelIfNeeded(reason: String) {
#if DEBUG
if self.testingSkipControlChannelRefresh {
return
}
#endif
switch ControlChannel.shared.state {
case .connected, .connecting:
return
@@ -421,6 +427,10 @@ extension GatewayProcessManager {
self.testingConnection = connection
}
func setTestingSkipControlChannelRefresh(_ skip: Bool) {
self.testingSkipControlChannelRefresh = skip
}
func setTestingDesiredActive(_ active: Bool) {
self.desiredActive = active
}
@@ -428,5 +438,9 @@ extension GatewayProcessManager {
func setTestingLastFailureReason(_ reason: String?) {
self.lastFailureReason = reason
}
func _testAttachExistingGatewayIfAvailable() async -> Bool {
await self.attachExistingGatewayIfAvailable()
}
}
#endif
+26 -2
View File
@@ -23,6 +23,9 @@ actor PortGuardian {
private var records: [Record] = []
private let logger = Logger(subsystem: "ai.openclaw", category: "portguard")
#if DEBUG
private var testingDescriptors: [Int: Descriptor] = [:]
#endif
private nonisolated static let appSupportDir: URL = {
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("OpenClaw", isDirectory: true)
@@ -130,6 +133,11 @@ actor PortGuardian {
}
func describe(port: Int) async -> Descriptor? {
#if DEBUG
if let descriptor = self.testingDescriptors[port] {
return descriptor
}
#endif
guard let listener = await self.listeners(on: port).first else { return nil }
let path = Self.executablePath(for: listener.pid)
return Descriptor(pid: listener.pid, command: listener.command, executablePath: path)
@@ -368,8 +376,12 @@ actor PortGuardian {
if port == GatewayEnvironment.gatewayPort() { return true }
return false
case .local:
// The gateway daemon may listen as `openclaw` or as its runtime (`node`, `bun`, etc).
if full.contains("gateway-daemon") { return true }
// Preserve both the legacy hidden alias and the current service process title.
if full.contains("gateway-daemon") || full.contains("openclaw-gateway")
|| cmd.contains("openclaw-gateway")
{
return true
}
// If args are unavailable, treat a CLI listener as expected.
if cmd.contains("openclaw"), full == cmd { return true }
return false
@@ -402,6 +414,18 @@ actor PortGuardian {
}
}
#if DEBUG
extension PortGuardian {
func setTestingDescriptor(_ descriptor: Descriptor?, forPort port: Int) {
if let descriptor {
self.testingDescriptors[port] = descriptor
} else {
self.testingDescriptors.removeValue(forKey: port)
}
}
}
#endif
#if DEBUG
extension PortGuardian {
static func _testParseListeners(_ text: String) -> [(
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.28-beta.1</string>
<string>2026.3.28</string>
<key>CFBundleVersion</key>
<string>2026032801</string>
<string>2026032800</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>
@@ -7,7 +7,7 @@ struct GatewayLaunchAgentManagerTests {
let url = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [
"ProgramArguments": ["openclaw", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
"ProgramArguments": ["openclaw", "gateway", "--port", "18789", "--bind", "loopback"],
"EnvironmentVariables": [
"OPENCLAW_GATEWAY_TOKEN": " secret ",
"OPENCLAW_GATEWAY_PASSWORD": "pw",
@@ -28,7 +28,7 @@ struct GatewayLaunchAgentManagerTests {
let url = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [
"ProgramArguments": ["openclaw", "gateway-daemon", "--port", "18789"],
"ProgramArguments": ["openclaw", "gateway", "--port", "18789"],
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: url, options: [.atomic])
@@ -35,4 +35,92 @@ struct GatewayProcessManagerTests {
#expect(ready)
#expect(manager.lastFailureReason == nil)
}
@Test func `attaches to existing gateway without spawning launchd`() async throws {
let healthData = Data(
"""
{
"ok": true,
"ts": 1,
"durationMs": 0,
"channels": {
"telegram": {
"configured": true,
"linked": true,
"authAgeMs": 60000
}
},
"channelOrder": ["telegram"],
"channelLabels": {
"telegram": "Telegram"
},
"heartbeatSeconds": 30,
"sessions": {
"path": "/tmp/sessions",
"count": 1,
"recent": []
}
}
""".utf8)
let session = GatewayTestWebSocketSession(
taskFactory: {
GatewayTestWebSocketTask(
sendHook: { task, message, sendIndex in
guard sendIndex > 0 else { return }
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": \(String(decoding: healthData, as: UTF8.self))
}
"""
task.emitReceiveSuccess(.data(Data(json.utf8)))
})
})
let url = try #require(URL(string: "ws://example.invalid"))
let connection = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },
sessionBox: WebSocketSessionBox(session: session))
let port = GatewayEnvironment.gatewayPort()
let descriptor = PortGuardian.Descriptor(
pid: 4242,
command: "openclaw-gateway",
executablePath: "/tmp/openclaw-gateway")
let manager = GatewayProcessManager.shared
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
manager.setTestingConnection(connection)
manager.setTestingSkipControlChannelRefresh(true)
manager.setTestingLastFailureReason("stale")
func cleanup() async {
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
manager.setTestingConnection(nil)
manager.setTestingSkipControlChannelRefresh(false)
manager.setTestingDesiredActive(false)
manager.setTestingLastFailureReason(nil)
}
do {
let attached = await manager._testAttachExistingGatewayIfAvailable()
#expect(attached)
#expect(manager.lastFailureReason == nil)
guard case let .attachedExisting(statusDetails) = manager.status else {
Issue.record("expected attachedExisting status")
await cleanup()
return
}
let details = try #require(statusDetails)
#expect(details.contains("port \(port)"))
#expect(details.contains("Telegram linked"))
#expect(details.contains("auth 1m"))
#expect(details.contains("pid 4242 openclaw-gateway @ /tmp/openclaw-gateway"))
await cleanup()
} catch {
await cleanup()
throw error
}
}
}
@@ -167,6 +167,11 @@ struct LowCoverageHelperTests {
fullCommand: "python server.py",
port: 18789, mode: .local) == false)
#expect(PortGuardian._testIsExpected(
command: "node",
fullCommand: "openclaw-gateway",
port: 18789, mode: .local) == true)
#expect(PortGuardian._testIsExpected(
command: "node",
fullCommand: "node /path/to/gateway-daemon",
@@ -3,17 +3,19 @@ import Testing
@testable import OpenClaw
@Suite(.serialized) struct NodeServiceManagerTests {
@Test func `builds node service commands with current CLI shape`() throws {
let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
@Test func `builds node service commands with current CLI shape`() async throws {
try await TestIsolation.withUserDefaultsValues(["openclaw.gatewayProjectRootPath": nil]) {
let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw")
try makeExecutableForTests(at: openclawPath)
let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw")
try makeExecutableForTests(at: openclawPath)
let start = NodeServiceManager._testServiceCommand(["start"])
#expect(start == [openclawPath.path, "node", "start", "--json"])
let start = NodeServiceManager._testServiceCommand(["start"])
#expect(start == [openclawPath.path, "node", "start", "--json"])
let stop = NodeServiceManager._testServiceCommand(["stop"])
#expect(stop == [openclawPath.path, "node", "stop", "--json"])
let stop = NodeServiceManager._testServiceCommand(["stop"])
#expect(stop == [openclawPath.path, "node", "stop", "--json"])
}
}
}
+22 -22
View File
@@ -253,7 +253,7 @@
"exportName": "CliBackendPlugin",
"kind": "type",
"source": {
"line": 1607,
"line": 1616,
"path": "src/plugins/types.ts"
}
},
@@ -397,7 +397,7 @@
"exportName": "MediaUnderstandingProviderPlugin",
"kind": "type",
"source": {
"line": 1258,
"line": 1267,
"path": "src/plugins/types.ts"
}
},
@@ -415,7 +415,7 @@
"exportName": "OpenClawPluginApi",
"kind": "type",
"source": {
"line": 1651,
"line": 1660,
"path": "src/plugins/types.ts"
}
},
@@ -523,7 +523,7 @@
"exportName": "SpeechProviderPlugin",
"kind": "type",
"source": {
"line": 1233,
"line": 1242,
"path": "src/plugins/types.ts"
}
},
@@ -3747,7 +3747,7 @@
"exportName": "MediaUnderstandingProviderPlugin",
"kind": "type",
"source": {
"line": 1258,
"line": 1267,
"path": "src/plugins/types.ts"
}
},
@@ -3765,7 +3765,7 @@
"exportName": "OpenClawPluginApi",
"kind": "type",
"source": {
"line": 1651,
"line": 1660,
"path": "src/plugins/types.ts"
}
},
@@ -3774,7 +3774,7 @@
"exportName": "OpenClawPluginCommandDefinition",
"kind": "type",
"source": {
"line": 1377,
"line": 1386,
"path": "src/plugins/types.ts"
}
},
@@ -3792,7 +3792,7 @@
"exportName": "OpenClawPluginDefinition",
"kind": "type",
"source": {
"line": 1633,
"line": 1642,
"path": "src/plugins/types.ts"
}
},
@@ -3801,7 +3801,7 @@
"exportName": "OpenClawPluginService",
"kind": "type",
"source": {
"line": 1600,
"line": 1609,
"path": "src/plugins/types.ts"
}
},
@@ -3810,7 +3810,7 @@
"exportName": "OpenClawPluginServiceContext",
"kind": "type",
"source": {
"line": 1592,
"line": 1601,
"path": "src/plugins/types.ts"
}
},
@@ -3837,7 +3837,7 @@
"exportName": "PluginCommandContext",
"kind": "type",
"source": {
"line": 1273,
"line": 1282,
"path": "src/plugins/types.ts"
}
},
@@ -3846,7 +3846,7 @@
"exportName": "PluginInteractiveTelegramHandlerContext",
"kind": "type",
"source": {
"line": 1406,
"line": 1415,
"path": "src/plugins/types.ts"
}
},
@@ -4170,7 +4170,7 @@
"exportName": "SpeechProviderPlugin",
"kind": "type",
"source": {
"line": 1233,
"line": 1242,
"path": "src/plugins/types.ts"
}
},
@@ -4262,7 +4262,7 @@
"exportName": "MediaUnderstandingProviderPlugin",
"kind": "type",
"source": {
"line": 1258,
"line": 1267,
"path": "src/plugins/types.ts"
}
},
@@ -4280,7 +4280,7 @@
"exportName": "OpenClawPluginApi",
"kind": "type",
"source": {
"line": 1651,
"line": 1660,
"path": "src/plugins/types.ts"
}
},
@@ -4289,7 +4289,7 @@
"exportName": "OpenClawPluginCommandDefinition",
"kind": "type",
"source": {
"line": 1377,
"line": 1386,
"path": "src/plugins/types.ts"
}
},
@@ -4307,7 +4307,7 @@
"exportName": "OpenClawPluginDefinition",
"kind": "type",
"source": {
"line": 1633,
"line": 1642,
"path": "src/plugins/types.ts"
}
},
@@ -4316,7 +4316,7 @@
"exportName": "OpenClawPluginService",
"kind": "type",
"source": {
"line": 1600,
"line": 1609,
"path": "src/plugins/types.ts"
}
},
@@ -4325,7 +4325,7 @@
"exportName": "OpenClawPluginServiceContext",
"kind": "type",
"source": {
"line": 1592,
"line": 1601,
"path": "src/plugins/types.ts"
}
},
@@ -4352,7 +4352,7 @@
"exportName": "PluginCommandContext",
"kind": "type",
"source": {
"line": 1273,
"line": 1282,
"path": "src/plugins/types.ts"
}
},
@@ -4361,7 +4361,7 @@
"exportName": "PluginInteractiveTelegramHandlerContext",
"kind": "type",
"source": {
"line": 1406,
"line": 1415,
"path": "src/plugins/types.ts"
}
},
@@ -4667,7 +4667,7 @@
"exportName": "SpeechProviderPlugin",
"kind": "type",
"source": {
"line": 1233,
"line": 1242,
"path": "src/plugins/types.ts"
}
}
+22 -22
View File
@@ -26,7 +26,7 @@
{"declaration":"export type ChannelStatusIssue = ChannelStatusIssue;","entrypoint":"index","exportName":"ChannelStatusIssue","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":100,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"index","exportName":"ClawdbotConfig","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type CliBackendConfig = CliBackendConfig;","entrypoint":"index","exportName":"CliBackendConfig","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":47,"sourcePath":"src/config/types.agent-defaults.ts"}
{"declaration":"export type CliBackendPlugin = CliBackendPlugin;","entrypoint":"index","exportName":"CliBackendPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1607,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type CliBackendPlugin = CliBackendPlugin;","entrypoint":"index","exportName":"CliBackendPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1616,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type CompiledConfiguredBinding = CompiledConfiguredBinding;","entrypoint":"index","exportName":"CompiledConfiguredBinding","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":38,"sourcePath":"src/channels/plugins/binding-types.ts"}
{"declaration":"export type ConfiguredBindingConversation = ConversationRef;","entrypoint":"index","exportName":"ConfiguredBindingConversation","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":13,"sourcePath":"src/channels/plugins/binding-types.ts"}
{"declaration":"export type ConfiguredBindingResolution = ConfiguredBindingResolution;","entrypoint":"index","exportName":"ConfiguredBindingResolution","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":49,"sourcePath":"src/channels/plugins/binding-types.ts"}
@@ -42,9 +42,9 @@
{"declaration":"export type ImageGenerationResolution = ImageGenerationResolution;","entrypoint":"index","exportName":"ImageGenerationResolution","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":12,"sourcePath":"src/image-generation/types.ts"}
{"declaration":"export type ImageGenerationResult = ImageGenerationResult;","entrypoint":"index","exportName":"ImageGenerationResult","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":36,"sourcePath":"src/image-generation/types.ts"}
{"declaration":"export type ImageGenerationSourceImage = ImageGenerationSourceImage;","entrypoint":"index","exportName":"ImageGenerationSourceImage","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":14,"sourcePath":"src/image-generation/types.ts"}
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"index","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1258,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"index","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1267,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"index","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"index","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1651,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"index","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1660,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"index","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":100,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginLogger = PluginLogger;","entrypoint":"index","exportName":"PluginLogger","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":71,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"index","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":54,"sourcePath":"src/plugins/runtime/types.ts"}
@@ -56,7 +56,7 @@
{"declaration":"export type RuntimeLogger = RuntimeLogger;","entrypoint":"index","exportName":"RuntimeLogger","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":7,"sourcePath":"src/plugins/runtime/types-core.ts"}
{"declaration":"export type SecretInput = SecretInput;","entrypoint":"index","exportName":"SecretInput","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":16,"sourcePath":"src/config/types.secrets.ts"}
{"declaration":"export type SecretRef = SecretRef;","entrypoint":"index","exportName":"SecretRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":10,"sourcePath":"src/config/types.secrets.ts"}
{"declaration":"export type SpeechProviderPlugin = SpeechProviderPlugin;","entrypoint":"index","exportName":"SpeechProviderPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1233,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type SpeechProviderPlugin = SpeechProviderPlugin;","entrypoint":"index","exportName":"SpeechProviderPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1242,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type StatefulBindingTargetDescriptor = StatefulBindingTargetDescriptor;","entrypoint":"index","exportName":"StatefulBindingTargetDescriptor","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":17,"sourcePath":"src/channels/plugins/binding-types.ts"}
{"declaration":"export type StatefulBindingTargetDriver = StatefulBindingTargetDriver;","entrypoint":"index","exportName":"StatefulBindingTargetDriver","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":15,"sourcePath":"src/channels/plugins/stateful-target-drivers.ts"}
{"declaration":"export type StatefulBindingTargetReadyResult = StatefulBindingTargetReadyResult;","entrypoint":"index","exportName":"StatefulBindingTargetReadyResult","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":7,"sourcePath":"src/channels/plugins/stateful-target-drivers.ts"}
@@ -412,18 +412,18 @@
{"declaration":"export type ChannelPlugin = ChannelPlugin<ResolvedAccount, Probe, Audit>;","entrypoint":"core","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":77,"sourcePath":"src/channels/plugins/types.plugin.ts"}
{"declaration":"export type GatewayBindUrlResult = GatewayBindUrlResult;","entrypoint":"core","exportName":"GatewayBindUrlResult","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1,"sourcePath":"src/shared/gateway-bind-url.ts"}
{"declaration":"export type GatewayRequestHandlerOptions = GatewayRequestHandlerOptions;","entrypoint":"core","exportName":"GatewayRequestHandlerOptions","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":115,"sourcePath":"src/gateway/server-methods/types.ts"}
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"core","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1258,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"core","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1267,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"core","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1651,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"core","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1377,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1660,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"core","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1386,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"core","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":100,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"core","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1633,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"core","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1600,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"core","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1592,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"core","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1642,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"core","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1609,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"core","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1601,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginToolContext = OpenClawPluginToolContext;","entrypoint":"core","exportName":"OpenClawPluginToolContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":115,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginToolFactory = OpenClawPluginToolFactory;","entrypoint":"core","exportName":"OpenClawPluginToolFactory","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":140,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"core","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1273,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"core","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1406,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"core","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1282,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"core","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1415,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginLogger = PluginLogger;","entrypoint":"core","exportName":"PluginLogger","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":71,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"core","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":54,"sourcePath":"src/plugins/runtime/types.ts"}
{"declaration":"export type ProviderAugmentModelCatalogContext = ProviderAugmentModelCatalogContext;","entrypoint":"core","exportName":"ProviderAugmentModelCatalogContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":709,"sourcePath":"src/plugins/types.ts"}
@@ -459,7 +459,7 @@
{"declaration":"export type RoutePeerKind = ChatType;","entrypoint":"core","exportName":"RoutePeerKind","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":19,"sourcePath":"src/routing/resolve-route.ts"}
{"declaration":"export type SecretFileReadOptions = SecretFileReadOptions;","entrypoint":"core","exportName":"SecretFileReadOptions","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":7,"sourcePath":"src/infra/secret-file.ts"}
{"declaration":"export type SecretFileReadResult = SecretFileReadResult;","entrypoint":"core","exportName":"SecretFileReadResult","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":12,"sourcePath":"src/infra/secret-file.ts"}
{"declaration":"export type SpeechProviderPlugin = SpeechProviderPlugin;","entrypoint":"core","exportName":"SpeechProviderPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1233,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type SpeechProviderPlugin = SpeechProviderPlugin;","entrypoint":"core","exportName":"SpeechProviderPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1242,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type TailscaleStatusCommandResult = TailscaleStatusCommandResult;","entrypoint":"core","exportName":"TailscaleStatusCommandResult","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":4,"sourcePath":"src/shared/tailscale-status.ts"}
{"declaration":"export type TailscaleStatusCommandRunner = TailscaleStatusCommandRunner;","entrypoint":"core","exportName":"TailscaleStatusCommandRunner","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":9,"sourcePath":"src/shared/tailscale-status.ts"}
{"declaration":"export type UsageProviderId = UsageProviderId;","entrypoint":"core","exportName":"UsageProviderId","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":20,"sourcePath":"src/infra/provider-usage.types.ts"}
@@ -469,18 +469,18 @@
{"declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"plugin-entry","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export","sourceLine":137,"sourcePath":"src/plugin-sdk/plugin-entry.ts"}
{"declaration":"export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"emptyPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export","sourceLine":108,"sourcePath":"src/plugins/config-schema.ts"}
{"declaration":"export type AnyAgentTool = AnyAgentTool;","entrypoint":"plugin-entry","exportName":"AnyAgentTool","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":9,"sourcePath":"src/agents/tools/common.ts"}
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"plugin-entry","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1258,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"plugin-entry","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1267,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"plugin-entry","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-entry","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1651,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1377,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-entry","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1660,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1386,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":100,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1633,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"plugin-entry","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1600,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"plugin-entry","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1592,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1642,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"plugin-entry","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1609,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"plugin-entry","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1601,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginToolContext = OpenClawPluginToolContext;","entrypoint":"plugin-entry","exportName":"OpenClawPluginToolContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":115,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginToolFactory = OpenClawPluginToolFactory;","entrypoint":"plugin-entry","exportName":"OpenClawPluginToolFactory","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":140,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"plugin-entry","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1273,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"plugin-entry","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1406,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"plugin-entry","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1282,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"plugin-entry","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1415,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginLogger = PluginLogger;","entrypoint":"plugin-entry","exportName":"PluginLogger","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":71,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type ProviderAugmentModelCatalogContext = ProviderAugmentModelCatalogContext;","entrypoint":"plugin-entry","exportName":"ProviderAugmentModelCatalogContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":709,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type ProviderAuthContext = ProviderAuthContext;","entrypoint":"plugin-entry","exportName":"ProviderAuthContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":175,"sourcePath":"src/plugins/types.ts"}
@@ -514,7 +514,7 @@
{"declaration":"export type ProviderRuntimeModel = ProviderRuntimeModel;","entrypoint":"plugin-entry","exportName":"ProviderRuntimeModel","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":316,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type ProviderThinkingPolicyContext = ProviderThinkingPolicyContext;","entrypoint":"plugin-entry","exportName":"ProviderThinkingPolicyContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":674,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type ProviderWrapStreamFnContext = ProviderWrapStreamFnContext;","entrypoint":"plugin-entry","exportName":"ProviderWrapStreamFnContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":560,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type SpeechProviderPlugin = SpeechProviderPlugin;","entrypoint":"plugin-entry","exportName":"SpeechProviderPlugin","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1233,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type SpeechProviderPlugin = SpeechProviderPlugin;","entrypoint":"plugin-entry","exportName":"SpeechProviderPlugin","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1242,"sourcePath":"src/plugins/types.ts"}
{"category":"provider","entrypoint":"provider-onboard","importSpecifier":"openclaw/plugin-sdk/provider-onboard","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/provider-onboard.ts"}
{"declaration":"export function applyAgentDefaultModelPrimary(cfg: OpenClawConfig, primary: string): OpenClawConfig;","entrypoint":"provider-onboard","exportName":"applyAgentDefaultModelPrimary","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":267,"sourcePath":"src/plugin-sdk/provider-onboard.ts"}
{"declaration":"export function applyOnboardAuthAgentModelsAndProviders(cfg: OpenClawConfig, params: { agentModels: Record<string, AgentModelEntryConfig>; providers: Record<string, ModelProviderConfig>; }): OpenClawConfig;","entrypoint":"provider-onboard","exportName":"applyOnboardAuthAgentModelsAndProviders","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":244,"sourcePath":"src/plugin-sdk/provider-onboard.ts"}
+17
View File
@@ -470,6 +470,23 @@ Matrix supports native Matrix threads for both automatic replies and message-too
- Top-level Matrix room/DM `/focus` creates a new Matrix thread and binds it to the target session when `threadBindings.spawnSubagentSessions=true`.
- Running `/focus` or `/acp spawn --thread here` inside an existing Matrix thread binds that current thread instead.
## ACP conversation bindings
Matrix rooms, DMs, and existing Matrix threads can be turned into durable ACP workspaces without changing the chat surface.
Fast operator flow:
- Run `/acp spawn codex --bind here` inside the Matrix DM, room, or existing thread you want to keep using.
- In a top-level Matrix DM or room, the current DM/room stays the chat surface and future messages route to the spawned ACP session.
- Inside an existing Matrix thread, `--bind here` binds that current thread in place.
- `/new` and `/reset` reset the same bound ACP session in place.
- `/acp close` closes the ACP session and removes the binding.
Notes:
- `--bind here` does not create a child Matrix thread.
- `threadBindings.spawnAcpSessions` is only required for `/acp spawn --thread auto|here`, where OpenClaw needs to create or bind a child Matrix thread.
### Thread Binding Config
Matrix inherits global defaults from `session.threadBindings`, and also supports per-channel overrides:
+6
View File
@@ -15,6 +15,11 @@ Note: The **Model** section now includes a multi-select for the
Tip: `openclaw config` without a subcommand opens the same wizard. Use
`openclaw config get|set|unset` for non-interactive edits.
For web search, `openclaw configure --section web` lets you choose a provider
and configure its credentials. If you choose **Grok**, configure can also show
a separate follow-up step to enable `x_search` with the same `XAI_API_KEY` and
pick an `x_search` model. Other web-search providers do not show that step.
Related:
- Gateway configuration reference: [Configuration](/gateway/configuration)
@@ -32,5 +37,6 @@ Notes:
```bash
openclaw configure
openclaw configure --section web
openclaw configure --section model --section channels
```
+3
View File
@@ -140,6 +140,9 @@ Flow notes:
- `quickstart`: minimal prompts, auto-generates a gateway token.
- `manual`: full prompts for port/bind/auth (alias of `advanced`).
- In the web-search step, choosing **Grok** can trigger a separate follow-up
prompt to enable `x_search` with the same `XAI_API_KEY` and optionally pick
an `x_search` model. Other web-search providers do not show that prompt.
- Local onboarding DM scope behavior: [CLI Setup Reference](/start/wizard-cli-reference#outputs-and-internals).
- Fastest first chat: `openclaw dashboard` (Control UI, no channel setup).
- Custom Provider: connect any OpenAI or Anthropic compatible endpoint,
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+16
View File
@@ -21,6 +21,22 @@ For post-level X metrics such as reposts, replies, bookmarks, or views, prefer
`x_search` with the exact post URL or status ID instead of a broad search
query.
## Onboarding and configure
If you choose **Grok** during:
- `openclaw onboard`
- `openclaw configure --section web`
OpenClaw can show a separate follow-up step to enable `x_search` with the same
`XAI_API_KEY`. That follow-up:
- only appears after you choose Grok for `web_search`
- is not a separate top-level web-search provider choice
- can optionally set the `x_search` model during the same flow
If you skip it, you can enable or change `x_search` later in config.
## Get an API key
<Steps>
+5
View File
@@ -149,6 +149,11 @@ examples.
For `x_search`, configure `tools.web.x_search.*` directly. It uses the same
`XAI_API_KEY` fallback as Grok web search.
When you choose Grok during `openclaw onboard` or `openclaw configure --section web`,
OpenClaw can also offer optional `x_search` setup with the same key.
This is a separate follow-up step inside the Grok path, not a separate top-level
web-search provider choice. If you pick another provider, OpenClaw does not
show the `x_search` prompt.
### Storing API keys
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+47
View File
@@ -0,0 +1,47 @@
# Extensions Boundary
This directory contains bundled plugins. Treat it as the same boundary that
third-party plugins see.
## Public Contracts
- Docs:
- `docs/plugins/building-plugins.md`
- `docs/plugins/architecture.md`
- `docs/plugins/sdk-overview.md`
- `docs/plugins/sdk-entrypoints.md`
- `docs/plugins/sdk-runtime.md`
- `docs/plugins/sdk-channel-plugins.md`
- `docs/plugins/sdk-provider-plugins.md`
- `docs/plugins/manifest.md`
- Definition files:
- `src/plugin-sdk/plugin-entry.ts`
- `src/plugin-sdk/core.ts`
- `src/plugin-sdk/provider-entry.ts`
- `src/plugin-sdk/channel-contract.ts`
- `scripts/lib/plugin-sdk-entrypoints.json`
- `package.json`
## Boundary Rules
- Extension production code should import from `openclaw/plugin-sdk/*` and its
own local barrels such as `./api.ts` and `./runtime-api.ts`.
- Do not import core internals from `src/**`, `src/channels/**`,
`src/plugin-sdk-internal/**`, or another extension's `src/**`.
- Do not use relative imports that escape the current extension package root.
- Keep plugin metadata accurate in `openclaw.plugin.json` and the package
`openclaw` block so discovery and setup work without executing plugin code.
- Treat files like `src/**`, `onboard.ts`, and other local helpers as private
unless you intentionally promote them through `api.ts` and, if needed, a
matching `src/plugin-sdk/<id>.ts` facade.
- If core or core tests need a bundled plugin helper, export it from `api.ts`
first instead of letting them deep-import extension internals.
## Expanding The Boundary
- If an extension needs a new seam, add a typed Plugin SDK subpath or additive
export instead of reaching into core.
- Keep new plugin-facing seams backwards-compatible and versioned. Third-party
plugins consume this surface.
- When intentionally expanding the contract, update the docs, exported subpath
list, package exports, and API/contract checks in the same change.
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/acpx",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"description": "OpenClaw ACP runtime backend via acpx",
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw Amazon Bedrock provider plugin",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-provider",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw Anthropic provider plugin",
"type": "module",
+2 -2
View File
@@ -1,13 +1,13 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"devDependencies": {
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.28-beta.1"
"openclaw": ">=2026.3.28"
},
"peerDependenciesMeta": {
"openclaw": {
+2 -2
View File
@@ -1,4 +1,4 @@
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import {
adaptScopedAccountAccessor,
@@ -58,7 +58,7 @@ export const bluebubblesConfigAdapter =
});
export function describeBlueBubblesAccount(account: ResolvedBlueBubblesAccount) {
return describeAccountSnapshot({
return describeWebhookAccountSnapshot({
account,
configured: account.configured,
extra: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw Brave plugin",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/browser-plugin",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw browser tool plugin",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/byteplus-provider",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw BytePlus provider plugin",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/chutes-provider",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw Chutes.ai provider plugin",
"type": "module",
@@ -1,6 +1,6 @@
{
"name": "@openclaw/cloudflare-ai-gateway-provider",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw Cloudflare AI Gateway provider plugin",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/deepgram-provider",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw Deepgram media-understanding provider",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/deepseek-provider",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw DeepSeek provider plugin",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/diffs",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw diff viewer plugin",
"type": "module",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"dependencies": {
@@ -14,7 +14,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.28-beta.1"
"openclaw": ">=2026.3.28"
},
"peerDependenciesMeta": {
"openclaw": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/duckduckgo-plugin",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw DuckDuckGo plugin",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/elevenlabs-speech",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw ElevenLabs speech plugin",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/exa-plugin",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw Exa plugin",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/fal-provider",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw fal provider plugin",
"type": "module",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/feishu",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {
@@ -12,7 +12,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.28-beta.1"
"openclaw": ">=2026.3.28"
},
"peerDependenciesMeta": {
"openclaw": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/firecrawl-plugin",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw Firecrawl plugin",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/github-copilot-provider",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw GitHub Copilot provider plugin",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-plugin",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw Google plugin",
"type": "module",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/googlechat",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw Google Chat channel plugin",
"type": "module",
@@ -11,7 +11,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.28-beta.1"
"openclaw": ">=2026.3.28"
},
"peerDependenciesMeta": {
"openclaw": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/groq-provider",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw Groq media-understanding provider",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/huggingface-provider",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw Hugging Face provider plugin",
"type": "module",
@@ -1,6 +1,6 @@
{
"name": "@openclaw/image-generation-core",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw image generation runtime package",
"type": "module"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/imessage",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw iMessage channel plugin",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/irc",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"description": "OpenClaw IRC channel plugin",
"type": "module",
"openclaw": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/kilocode-provider",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw Kilo Gateway provider plugin",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/kimi-provider",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw Kimi provider plugin",
"type": "module",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/line",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw LINE channel plugin",
"type": "module",
@@ -8,7 +8,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.28-beta.1"
"openclaw": ">=2026.3.28"
},
"peerDependenciesMeta": {
"openclaw": {
+9 -7
View File
@@ -1,3 +1,4 @@
import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import type { ChannelPlugin } from "../api.js";
import {
resolveLineAccount,
@@ -37,13 +38,14 @@ export const lineChannelPluginCommon = {
config: {
...lineConfigAdapter,
isConfigured: (account: ResolvedLineAccount) => hasLineCredentials(account),
describeAccount: (account: ResolvedLineAccount) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: hasLineCredentials(account),
tokenSource: account.tokenSource ?? undefined,
}),
describeAccount: (account: ResolvedLineAccount) =>
describeWebhookAccountSnapshot({
account,
configured: hasLineCredentials(account),
extra: {
tokenSource: account.tokenSource ?? undefined,
},
}),
},
} satisfies Pick<
ChannelPlugin<ResolvedLineAccount>,
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import type { ChannelAccountSnapshot } from "../api.js";
import { linePlugin } from "./channel.js";
function collectIssues(accounts: ChannelAccountSnapshot[]) {
const collect = linePlugin.status?.collectStatusIssues;
if (!collect) {
throw new Error("LINE plugin status collector is unavailable");
}
return collect(accounts);
}
describe("linePlugin status.collectStatusIssues", () => {
it("does not warn when a sanitized snapshot is configured", () => {
expect(
collectIssues([
{
accountId: "default",
configured: true,
tokenSource: "env",
},
]),
).toEqual([]);
});
it("reports missing access token when the snapshot is unconfigured and tokenSource is none", () => {
expect(
collectIssues([
{
accountId: "default",
configured: false,
tokenSource: "none",
},
]),
).toEqual([
{
channel: "line",
accountId: "default",
kind: "config",
message: "LINE channel access token not configured",
},
]);
});
it("reports missing secret when the snapshot is unconfigured but a token source exists", () => {
expect(
collectIssues([
{
accountId: "default",
configured: false,
tokenSource: "env",
},
]),
).toEqual([
{
channel: "line",
accountId: "default",
kind: "config",
message: "LINE channel secret not configured",
},
]);
});
});
+7 -396
View File
@@ -1,34 +1,16 @@
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy";
import {
createAttachedChannelResultAdapter,
createEmptyChannelResult,
} from "openclaw/plugin-sdk/channel-send-result";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import {
buildTokenChannelStatusSummary,
clearAccountEntryFields,
DEFAULT_ACCOUNT_ID,
processLineMessage,
type ChannelPlugin,
type ChannelStatusIssue,
type LineConfig,
type LineChannelData,
type OpenClawConfig,
type ResolvedLineAccount,
} from "../api.js";
import { type ChannelPlugin, type ResolvedLineAccount } from "../api.js";
import { lineChannelPluginCommon } from "./channel-shared.js";
import { lineGatewayAdapter } from "./gateway.js";
import { resolveLineGroupRequireMention } from "./group-policy.js";
import { probeLineBot } from "./probe.js";
import { lineOutboundAdapter } from "./outbound.js";
import { getLineRuntime } from "./runtime.js";
import { lineSetupAdapter } from "./setup-core.js";
import { lineSetupWizard } from "./setup-surface.js";
import { lineStatusAdapter } from "./status.js";
const lineSecurityAdapter = createRestrictSendersChannelSecurity<ResolvedLineAccount>({
channelKey: "line",
@@ -77,157 +59,8 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
},
directory: createEmptyChannelDirectoryAdapter(),
setup: lineSetupAdapter,
status: createComputedAccountStatusAdapter<ResolvedLineAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
collectStatusIssues: (accounts) => {
const issues: ChannelStatusIssue[] = [];
for (const account of accounts) {
const accountId = account.accountId ?? DEFAULT_ACCOUNT_ID;
if (!account.channelAccessToken?.trim()) {
issues.push({
channel: "line",
accountId,
kind: "config",
message: "LINE channel access token not configured",
});
}
if (!account.channelSecret?.trim()) {
issues.push({
channel: "line",
accountId,
kind: "config",
message: "LINE channel secret not configured",
});
}
}
return issues;
},
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
probeAccount: async ({ account, timeoutMs }) =>
await probeLineBot(account.channelAccessToken, timeoutMs),
resolveAccountSnapshot: ({ account }) => {
const configured = Boolean(
account.channelAccessToken?.trim() && account.channelSecret?.trim(),
);
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
extra: {
tokenSource: account.tokenSource,
mode: "webhook",
},
};
},
}),
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const token = account.channelAccessToken.trim();
const secret = account.channelSecret.trim();
if (!token) {
throw new Error(
`LINE webhook mode requires a non-empty channel access token for account "${account.accountId}".`,
);
}
if (!secret) {
throw new Error(
`LINE webhook mode requires a non-empty channel secret for account "${account.accountId}".`,
);
}
let lineBotLabel = "";
try {
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
if (displayName) {
lineBotLabel = ` (${displayName})`;
}
} catch (err) {
if (getLineRuntime().logging.shouldLogVerbose()) {
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
}
}
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
return await getLineRuntime().channel.line.monitorLineProvider({
channelAccessToken: token,
channelSecret: secret,
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
});
},
logoutAccount: async ({ accountId, cfg }) => {
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
const nextCfg = { ...cfg } as OpenClawConfig;
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
const nextLine = { ...lineConfig };
let cleared = false;
let changed = false;
if (accountId === DEFAULT_ACCOUNT_ID) {
if (
nextLine.channelAccessToken ||
nextLine.channelSecret ||
nextLine.tokenFile ||
nextLine.secretFile
) {
delete nextLine.channelAccessToken;
delete nextLine.channelSecret;
delete nextLine.tokenFile;
delete nextLine.secretFile;
cleared = true;
changed = true;
}
}
const accountCleanup = clearAccountEntryFields({
accounts: nextLine.accounts,
accountId,
fields: ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"],
markClearedOnFieldPresence: true,
});
if (accountCleanup.changed) {
changed = true;
if (accountCleanup.cleared) {
cleared = true;
}
if (accountCleanup.nextAccounts) {
nextLine.accounts = accountCleanup.nextAccounts;
} else {
delete nextLine.accounts;
}
}
if (changed) {
if (Object.keys(nextLine).length > 0) {
nextCfg.channels = { ...nextCfg.channels, line: nextLine };
} else {
const nextChannels = { ...nextCfg.channels };
delete (nextChannels as Record<string, unknown>).line;
if (Object.keys(nextChannels).length > 0) {
nextCfg.channels = nextChannels;
} else {
delete nextCfg.channels;
}
}
await getLineRuntime().config.writeConfigFile(nextCfg);
}
const resolved = getLineRuntime().channel.line.resolveLineAccount({
cfg: changed ? nextCfg : cfg,
accountId,
});
const loggedOut = resolved.tokenSource === "none";
return { cleared, envToken: Boolean(envToken), loggedOut };
},
},
status: lineStatusAdapter,
gateway: lineGatewayAdapter,
agentPrompt: {
messageToolHints: () => [
"",
@@ -298,227 +131,5 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
},
},
security: lineSecurityAdapter,
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
textChunkLimit: 5000, // LINE allows up to 5000 characters per text message
sendPayload: async ({ to, payload, accountId, cfg }) => {
const runtime = getLineRuntime();
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
const sendText = runtime.channel.line.pushMessageLine;
const sendBatch = runtime.channel.line.pushMessagesLine;
const sendFlex = runtime.channel.line.pushFlexMessage;
const sendTemplate = runtime.channel.line.pushTemplateMessage;
const sendLocation = runtime.channel.line.pushLocationMessage;
const sendQuickReplies = runtime.channel.line.pushTextMessageWithQuickReplies;
const buildTemplate = runtime.channel.line.buildTemplateMessageFromPayload;
const createQuickReplyItems = runtime.channel.line.createQuickReplyItems;
let lastResult: { messageId: string; chatId: string } | null = null;
const quickReplies = lineData.quickReplies ?? [];
const hasQuickReplies = quickReplies.length > 0;
const quickReply = hasQuickReplies ? createQuickReplyItems(quickReplies) : undefined;
// oxlint-disable-next-line typescript/no-explicit-any
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
if (messages.length === 0) {
return;
}
for (let i = 0; i < messages.length; i += 5) {
// LINE SDK expects Message[] but we build dynamically
const batch = messages.slice(i, i + 5) as unknown as Parameters<typeof sendBatch>[1];
const result = await sendBatch(to, batch, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
lastResult = { messageId: result.messageId, chatId: result.chatId };
}
};
const processed = payload.text
? processLineMessage(payload.text)
: { text: "", flexMessages: [] };
const chunkLimit =
runtime.channel.text.resolveTextChunkLimit?.(cfg, "line", accountId ?? undefined, {
fallbackLimit: 5000,
}) ?? 5000;
const chunks = processed.text
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
: [];
const mediaUrls = resolveOutboundMediaUrls(payload);
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
const sendMediaMessages = async () => {
for (const url of mediaUrls) {
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
verbose: false,
mediaUrl: url,
cfg,
accountId: accountId ?? undefined,
});
}
};
if (!shouldSendQuickRepliesInline) {
if (lineData.flexMessage) {
// LINE SDK expects FlexContainer but we receive contents as unknown
const flexContents = lineData.flexMessage.contents as Parameters<typeof sendFlex>[2];
lastResult = await sendFlex(to, lineData.flexMessage.altText, flexContents, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
if (lineData.templateMessage) {
const template = buildTemplate(lineData.templateMessage);
if (template) {
lastResult = await sendTemplate(to, template, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
}
if (lineData.location) {
lastResult = await sendLocation(to, lineData.location, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
for (const flexMsg of processed.flexMessages) {
// LINE SDK expects FlexContainer but we receive contents as unknown
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
lastResult = await sendFlex(to, flexMsg.altText, flexContents, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
}
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
await sendMediaMessages();
}
if (chunks.length > 0) {
for (let i = 0; i < chunks.length; i += 1) {
const isLast = i === chunks.length - 1;
if (isLast && hasQuickReplies) {
lastResult = await sendQuickReplies(to, chunks[i], quickReplies, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
} else {
lastResult = await sendText(to, chunks[i], {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
}
} else if (shouldSendQuickRepliesInline) {
const quickReplyMessages: Array<Record<string, unknown>> = [];
if (lineData.flexMessage) {
quickReplyMessages.push({
type: "flex",
altText: lineData.flexMessage.altText.slice(0, 400),
contents: lineData.flexMessage.contents,
});
}
if (lineData.templateMessage) {
const template = buildTemplate(lineData.templateMessage);
if (template) {
quickReplyMessages.push(template);
}
}
if (lineData.location) {
quickReplyMessages.push({
type: "location",
title: lineData.location.title.slice(0, 100),
address: lineData.location.address.slice(0, 100),
latitude: lineData.location.latitude,
longitude: lineData.location.longitude,
});
}
for (const flexMsg of processed.flexMessages) {
quickReplyMessages.push({
type: "flex",
altText: flexMsg.altText.slice(0, 400),
contents: flexMsg.contents,
});
}
for (const url of mediaUrls) {
const trimmed = url?.trim();
if (!trimmed) {
continue;
}
quickReplyMessages.push({
type: "image",
originalContentUrl: trimmed,
previewImageUrl: trimmed,
});
}
if (quickReplyMessages.length > 0 && quickReply) {
const lastIndex = quickReplyMessages.length - 1;
quickReplyMessages[lastIndex] = {
...quickReplyMessages[lastIndex],
quickReply,
};
await sendMessageBatch(quickReplyMessages);
}
}
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
await sendMediaMessages();
}
if (lastResult) {
return createEmptyChannelResult("line", { ...lastResult });
}
return createEmptyChannelResult("line", { messageId: "empty", chatId: to });
},
...createAttachedChannelResultAdapter({
channel: "line",
sendText: async ({ cfg, to, text, accountId }) => {
const runtime = getLineRuntime();
const sendText = runtime.channel.line.pushMessageLine;
const sendFlex = runtime.channel.line.pushFlexMessage;
const processed = processLineMessage(text);
let result: { messageId: string; chatId: string };
if (processed.text.trim()) {
result = await sendText(to, processed.text, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
} else {
result = { messageId: "processed", chatId: to };
}
for (const flexMsg of processed.flexMessages) {
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
await sendFlex(to, flexMsg.altText, flexContents, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
return result;
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) =>
await getLineRuntime().channel.line.sendMessageLine(to, text, {
verbose: false,
mediaUrl,
cfg,
accountId: accountId ?? undefined,
}),
}),
},
outbound: lineOutboundAdapter,
});
+117
View File
@@ -0,0 +1,117 @@
import {
clearAccountEntryFields,
DEFAULT_ACCOUNT_ID,
type ChannelPlugin,
type LineConfig,
type OpenClawConfig,
type ResolvedLineAccount,
} from "../api.js";
import { getLineRuntime } from "./runtime.js";
export const lineGatewayAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["gateway"]> = {
startAccount: async (ctx) => {
const account = ctx.account;
const token = account.channelAccessToken.trim();
const secret = account.channelSecret.trim();
if (!token) {
throw new Error(
`LINE webhook mode requires a non-empty channel access token for account "${account.accountId}".`,
);
}
if (!secret) {
throw new Error(
`LINE webhook mode requires a non-empty channel secret for account "${account.accountId}".`,
);
}
let lineBotLabel = "";
try {
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
if (displayName) {
lineBotLabel = ` (${displayName})`;
}
} catch (err) {
if (getLineRuntime().logging.shouldLogVerbose()) {
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
}
}
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
return await getLineRuntime().channel.line.monitorLineProvider({
channelAccessToken: token,
channelSecret: secret,
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
});
},
logoutAccount: async ({ accountId, cfg }) => {
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
const nextCfg = { ...cfg } as OpenClawConfig;
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
const nextLine = { ...lineConfig };
let cleared = false;
let changed = false;
if (accountId === DEFAULT_ACCOUNT_ID) {
if (
nextLine.channelAccessToken ||
nextLine.channelSecret ||
nextLine.tokenFile ||
nextLine.secretFile
) {
delete nextLine.channelAccessToken;
delete nextLine.channelSecret;
delete nextLine.tokenFile;
delete nextLine.secretFile;
cleared = true;
changed = true;
}
}
const accountCleanup = clearAccountEntryFields({
accounts: nextLine.accounts,
accountId,
fields: ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"],
markClearedOnFieldPresence: true,
});
if (accountCleanup.changed) {
changed = true;
if (accountCleanup.cleared) {
cleared = true;
}
if (accountCleanup.nextAccounts) {
nextLine.accounts = accountCleanup.nextAccounts;
} else {
delete nextLine.accounts;
}
}
if (changed) {
if (Object.keys(nextLine).length > 0) {
nextCfg.channels = { ...nextCfg.channels, line: nextLine };
} else {
const nextChannels = { ...nextCfg.channels };
delete (nextChannels as Record<string, unknown>).line;
if (Object.keys(nextChannels).length > 0) {
nextCfg.channels = nextChannels;
} else {
delete nextCfg.channels;
}
}
await getLineRuntime().config.writeConfigFile(nextCfg);
}
const resolved = getLineRuntime().channel.line.resolveLineAccount({
cfg: changed ? nextCfg : cfg,
accountId,
});
const loggedOut = resolved.tokenSource === "none";
return { cleared, envToken: Boolean(envToken), loggedOut };
},
};
+233
View File
@@ -0,0 +1,233 @@
import {
createAttachedChannelResultAdapter,
createEmptyChannelResult,
} from "openclaw/plugin-sdk/channel-send-result";
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
import {
processLineMessage,
type ChannelPlugin,
type LineChannelData,
type ResolvedLineAccount,
} from "../api.js";
import { getLineRuntime } from "./runtime.js";
export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["outbound"]> = {
deliveryMode: "direct",
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
textChunkLimit: 5000,
sendPayload: async ({ to, payload, accountId, cfg }) => {
const runtime = getLineRuntime();
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
const sendText = runtime.channel.line.pushMessageLine;
const sendBatch = runtime.channel.line.pushMessagesLine;
const sendFlex = runtime.channel.line.pushFlexMessage;
const sendTemplate = runtime.channel.line.pushTemplateMessage;
const sendLocation = runtime.channel.line.pushLocationMessage;
const sendQuickReplies = runtime.channel.line.pushTextMessageWithQuickReplies;
const buildTemplate = runtime.channel.line.buildTemplateMessageFromPayload;
const createQuickReplyItems = runtime.channel.line.createQuickReplyItems;
let lastResult: { messageId: string; chatId: string } | null = null;
const quickReplies = lineData.quickReplies ?? [];
const hasQuickReplies = quickReplies.length > 0;
const quickReply = hasQuickReplies ? createQuickReplyItems(quickReplies) : undefined;
// LINE SDK expects Message[] but we build dynamically.
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
if (messages.length === 0) {
return;
}
for (let i = 0; i < messages.length; i += 5) {
const batch = messages.slice(i, i + 5) as unknown as Parameters<typeof sendBatch>[1];
const result = await sendBatch(to, batch, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
lastResult = { messageId: result.messageId, chatId: result.chatId };
}
};
const processed = payload.text
? processLineMessage(payload.text)
: { text: "", flexMessages: [] };
const chunkLimit =
runtime.channel.text.resolveTextChunkLimit?.(cfg, "line", accountId ?? undefined, {
fallbackLimit: 5000,
}) ?? 5000;
const chunks = processed.text
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
: [];
const mediaUrls = resolveOutboundMediaUrls(payload);
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
const sendMediaMessages = async () => {
for (const url of mediaUrls) {
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
verbose: false,
mediaUrl: url,
cfg,
accountId: accountId ?? undefined,
});
}
};
if (!shouldSendQuickRepliesInline) {
if (lineData.flexMessage) {
const flexContents = lineData.flexMessage.contents as Parameters<typeof sendFlex>[2];
lastResult = await sendFlex(to, lineData.flexMessage.altText, flexContents, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
if (lineData.templateMessage) {
const template = buildTemplate(lineData.templateMessage);
if (template) {
lastResult = await sendTemplate(to, template, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
}
if (lineData.location) {
lastResult = await sendLocation(to, lineData.location, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
for (const flexMsg of processed.flexMessages) {
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
lastResult = await sendFlex(to, flexMsg.altText, flexContents, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
}
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
await sendMediaMessages();
}
if (chunks.length > 0) {
for (let i = 0; i < chunks.length; i += 1) {
const isLast = i === chunks.length - 1;
if (isLast && hasQuickReplies) {
lastResult = await sendQuickReplies(to, chunks[i], quickReplies, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
} else {
lastResult = await sendText(to, chunks[i], {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
}
} else if (shouldSendQuickRepliesInline) {
const quickReplyMessages: Array<Record<string, unknown>> = [];
if (lineData.flexMessage) {
quickReplyMessages.push({
type: "flex",
altText: lineData.flexMessage.altText.slice(0, 400),
contents: lineData.flexMessage.contents,
});
}
if (lineData.templateMessage) {
const template = buildTemplate(lineData.templateMessage);
if (template) {
quickReplyMessages.push(template);
}
}
if (lineData.location) {
quickReplyMessages.push({
type: "location",
title: lineData.location.title.slice(0, 100),
address: lineData.location.address.slice(0, 100),
latitude: lineData.location.latitude,
longitude: lineData.location.longitude,
});
}
for (const flexMsg of processed.flexMessages) {
quickReplyMessages.push({
type: "flex",
altText: flexMsg.altText.slice(0, 400),
contents: flexMsg.contents,
});
}
for (const url of mediaUrls) {
const trimmed = url?.trim();
if (!trimmed) {
continue;
}
quickReplyMessages.push({
type: "image",
originalContentUrl: trimmed,
previewImageUrl: trimmed,
});
}
if (quickReplyMessages.length > 0 && quickReply) {
const lastIndex = quickReplyMessages.length - 1;
quickReplyMessages[lastIndex] = {
...quickReplyMessages[lastIndex],
quickReply,
};
await sendMessageBatch(quickReplyMessages);
}
}
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
await sendMediaMessages();
}
if (lastResult) {
return createEmptyChannelResult("line", { ...lastResult });
}
return createEmptyChannelResult("line", { messageId: "empty", chatId: to });
},
...createAttachedChannelResultAdapter({
channel: "line",
sendText: async ({ cfg, to, text, accountId }) => {
const runtime = getLineRuntime();
const sendText = runtime.channel.line.pushMessageLine;
const sendFlex = runtime.channel.line.pushFlexMessage;
const processed = processLineMessage(text);
let result: { messageId: string; chatId: string };
if (processed.text.trim()) {
result = await sendText(to, processed.text, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
} else {
result = { messageId: "processed", chatId: to };
}
for (const flexMsg of processed.flexMessages) {
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
await sendFlex(to, flexMsg.altText, flexContents, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
return result;
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) =>
await getLineRuntime().channel.line.sendMessageLine(to, text, {
verbose: false,
mediaUrl,
cfg,
accountId: accountId ?? undefined,
}),
}),
};
+34
View File
@@ -0,0 +1,34 @@
import crypto from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import { validateLineSignature } from "./signature.js";
function sign(body: string, secret: string): string {
return crypto.createHmac("SHA256", secret).update(body).digest("base64");
}
describe("validateLineSignature", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("accepts a valid signature", () => {
const body = JSON.stringify({ events: [{ type: "message" }] });
const secret = "top-secret";
expect(validateLineSignature(body, sign(body, secret), secret)).toBe(true);
});
it("still performs timing-safe comparison when signature length mismatches", () => {
const body = JSON.stringify({ events: [{ type: "message" }] });
const secret = "top-secret";
const spy = vi.spyOn(crypto, "timingSafeEqual");
expect(validateLineSignature(body, "short", secret)).toBe(false);
expect(spy).toHaveBeenCalledTimes(1);
const [left, right] = spy.mock.calls[0] ?? [];
expect(left).toBeInstanceOf(Buffer);
expect(right).toBeInstanceOf(Buffer);
expect(left?.byteLength).toBe(right?.byteLength);
});
});
+11 -5
View File
@@ -9,10 +9,16 @@ export function validateLineSignature(
const hashBuffer = Buffer.from(hash);
const signatureBuffer = Buffer.from(signature);
// Use constant-time comparison to prevent timing attacks.
if (hashBuffer.length !== signatureBuffer.length) {
return false;
}
// Pad to equal length before constant-time comparison to prevent
// leaking length information via early-return timing.
const maxLen = Math.max(hashBuffer.length, signatureBuffer.length);
const paddedHash = Buffer.alloc(maxLen);
const paddedSig = Buffer.alloc(maxLen);
hashBuffer.copy(paddedHash);
signatureBuffer.copy(paddedSig);
return crypto.timingSafeEqual(hashBuffer, signatureBuffer);
// Call timingSafeEqual unconditionally to ensure constant-time execution
// regardless of length mismatch (avoids && short-circuit timing leak).
const timingResult = crypto.timingSafeEqual(paddedHash, paddedSig);
return hashBuffer.length === signatureBuffer.length && timingResult;
}
+35
View File
@@ -0,0 +1,35 @@
import {
buildTokenChannelStatusSummary,
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
createDependentCredentialStatusIssueCollector,
} from "openclaw/plugin-sdk/status-helpers";
import { DEFAULT_ACCOUNT_ID, type ChannelPlugin, type ResolvedLineAccount } from "../api.js";
import { hasLineCredentials } from "./account-helpers.js";
import { probeLineBot } from "./probe.js";
const collectLineStatusIssues = createDependentCredentialStatusIssueCollector({
channel: "line",
dependencySourceKey: "tokenSource",
missingPrimaryMessage: "LINE channel access token not configured",
missingDependentMessage: "LINE channel secret not configured",
});
export const lineStatusAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["status"]> =
createComputedAccountStatusAdapter<ResolvedLineAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
collectStatusIssues: collectLineStatusIssues,
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
probeAccount: async ({ account, timeoutMs }) =>
await probeLineBot(account.channelAccessToken, timeoutMs),
resolveAccountSnapshot: ({ account }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: hasLineCredentials(account),
extra: {
tokenSource: account.tokenSource,
mode: "webhook",
},
}),
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/litellm-provider",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw LiteLLM provider plugin",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/llm-task",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw JSON-only LLM task plugin",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/lobster",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/matrix",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {
@@ -14,7 +14,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.28-beta.1"
"openclaw": ">=2026.3.28"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -52,6 +52,7 @@ export type FileWithThumbnailInfo = {
size?: number;
mimetype?: string;
thumbnail_url?: string;
thumbnail_file?: EncryptedFile;
thumbnail_info?: {
w?: number;
h?: number;
+42 -7
View File
@@ -172,19 +172,52 @@ describe("sendMessageMatrix media", () => {
expect(content.file?.url).toBe("mxc://example/file");
});
it("does not upload plaintext thumbnails for encrypted image sends", async () => {
const { client, uploadContent } = makeEncryptedMediaClient();
it("encrypts thumbnail via thumbnail_file when room is encrypted", async () => {
const { client, sendMessage, uploadContent } = makeClient();
const isRoomEncrypted = vi.fn().mockResolvedValue(true);
const encryptMedia = vi.fn().mockResolvedValue({
buffer: Buffer.from("encrypted-thumb"),
file: {
key: { kty: "oct", key_ops: ["encrypt", "decrypt"], alg: "A256CTR", k: "tkey", ext: true },
iv: "tiv",
hashes: { sha256: "thash" },
v: "v2",
},
});
(client as { crypto?: object }).crypto = {
isRoomEncrypted,
encryptMedia,
};
// Return image metadata so thumbnail generation is triggered (image > 800px)
getImageMetadataMock
.mockResolvedValueOnce({ width: 1600, height: 1200 })
.mockResolvedValueOnce({ width: 800, height: 600 });
resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb"));
.mockResolvedValueOnce({ width: 1920, height: 1080 }) // original image
.mockResolvedValueOnce({ width: 800, height: 450 }); // thumbnail
resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb-bytes"));
// Two uploadContent calls: one for the main encrypted image, one for the encrypted thumbnail
uploadContent
.mockResolvedValueOnce("mxc://example/main")
.mockResolvedValueOnce("mxc://example/thumb");
await sendMessageMatrix("room:!room:example", "caption", {
client,
mediaUrl: "file:///tmp/photo.png",
});
expect(uploadContent).toHaveBeenCalledTimes(1);
// encryptMedia called twice: once for main media, once for thumbnail
expect(isRoomEncrypted).toHaveBeenCalledTimes(1);
expect(encryptMedia).toHaveBeenCalledTimes(2);
const content = sendMessage.mock.calls[0]?.[1] as {
url?: string;
file?: { url?: string };
info?: { thumbnail_url?: string; thumbnail_file?: { url?: string } };
};
// Main media encrypted correctly
expect(content.url).toBeUndefined();
expect(content.file?.url).toBe("mxc://example/main");
// Thumbnail must use thumbnail_file (encrypted), NOT thumbnail_url (unencrypted)
expect(content.info?.thumbnail_url).toBeUndefined();
expect(content.info?.thumbnail_file?.url).toBe("mxc://example/thumb");
});
it("keeps reply context on voice transcript follow-ups outside threads", async () => {
@@ -246,7 +279,7 @@ describe("sendMessageMatrix media", () => {
expect(mediaContent["org.matrix.msc3245.voice"]).toBeUndefined();
});
it("uploads thumbnail metadata for unencrypted large images", async () => {
it("keeps thumbnail_url metadata for unencrypted large images", async () => {
const { client, sendMessage, uploadContent } = makeClient();
getImageMetadataMock
.mockResolvedValueOnce({ width: 1600, height: 1200 })
@@ -262,6 +295,7 @@ describe("sendMessageMatrix media", () => {
const content = sendMessage.mock.calls[0]?.[1] as {
info?: {
thumbnail_url?: string;
thumbnail_file?: { url?: string };
thumbnail_info?: {
w?: number;
h?: number;
@@ -271,6 +305,7 @@ describe("sendMessageMatrix media", () => {
};
};
expect(content.info?.thumbnail_url).toBe("mxc://example/file");
expect(content.info?.thumbnail_file).toBeUndefined();
expect(content.info?.thumbnail_info).toMatchObject({
w: 800,
h: 600,
+38 -26
View File
@@ -122,10 +122,6 @@ export async function prepareImageInfo(params: {
return undefined;
}
const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
if (params.encrypted) {
// For E2EE media, avoid uploading plaintext thumbnails.
return imageInfo;
}
const maxDim = Math.max(meta.width, meta.height);
if (maxDim > THUMBNAIL_MAX_SIDE) {
try {
@@ -138,12 +134,16 @@ export async function prepareImageInfo(params: {
const thumbMeta = await getCore()
.media.getImageMetadata(thumbBuffer)
.catch(() => null);
const thumbUri = await params.client.uploadContent(
thumbBuffer,
"image/jpeg",
"thumbnail.jpg",
);
imageInfo.thumbnail_url = thumbUri;
const result = await uploadMediaWithEncryption(params.client, thumbBuffer, {
contentType: "image/jpeg",
filename: "thumbnail.jpg",
encrypted: params.encrypted === true,
});
if (result.file) {
imageInfo.thumbnail_file = result.file;
} else {
imageInfo.thumbnail_url = result.url;
}
if (thumbMeta) {
imageInfo.thumbnail_info = {
w: thumbMeta.width,
@@ -202,6 +202,29 @@ async function uploadFile(
return await client.uploadContent(file, params.contentType, params.filename);
}
async function uploadMediaWithEncryption(
client: MatrixClient,
buffer: Buffer,
params: {
contentType?: string;
filename?: string;
encrypted: boolean;
},
): Promise<{ url: string; file?: EncryptedFile }> {
if (params.encrypted && client.crypto) {
const encrypted = await client.crypto.encryptMedia(buffer);
const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename);
const file: EncryptedFile = { url: mxc, ...encrypted.file };
return {
url: mxc,
file,
};
}
const mxc = await uploadFile(client, buffer, params);
return { url: mxc };
}
/**
* Upload media with optional encryption for E2EE rooms.
*/
@@ -215,20 +238,9 @@ export async function uploadMediaMaybeEncrypted(
},
): Promise<{ url: string; file?: EncryptedFile }> {
// Check if room is encrypted and crypto is available
const isEncrypted = client.crypto && (await client.crypto.isRoomEncrypted(roomId));
if (isEncrypted && client.crypto) {
// Encrypt the media before uploading
const encrypted = await client.crypto.encryptMedia(buffer);
const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename);
const file: EncryptedFile = { url: mxc, ...encrypted.file };
return {
url: mxc,
file,
};
}
// Upload unencrypted
const mxc = await uploadFile(client, buffer, params);
return { url: mxc };
const isEncrypted = Boolean(client.crypto && (await client.crypto.isRoomEncrypted(roomId)));
return await uploadMediaWithEncryption(client, buffer, {
...params,
encrypted: isEncrypted,
});
}
+2 -1
View File
@@ -1,2 +1,3 @@
export { mattermostPlugin } from "./src/channel.js";
// Keep this barrel helper-only so plugin-sdk facades do not pull the full
// channel plugin (and its runtime state) into tests or other shared surfaces.
export { isMattermostSenderAllowed } from "./src/mattermost/monitor-auth.js";
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/mattermost",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"description": "OpenClaw Mattermost channel plugin",
"type": "module",
"dependencies": {
@@ -11,7 +11,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.28-beta.1"
"openclaw": ">=2026.3.28"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -1,6 +1,6 @@
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
import { createAccountListHelpers, type OpenClawConfig } from "../runtime-api.js";
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js";
import type {
MattermostAccountConfig,
@@ -9,6 +9,7 @@ import type {
MattermostReplyToMode,
} from "../types.js";
import { normalizeMattermostBaseUrl } from "./client.js";
import type { OpenClawConfig } from "./runtime-api.js";
export type MattermostTokenSource = "env" | "config" | "none";
export type MattermostBaseUrlSource = "env" | "config" | "none";
@@ -30,11 +31,15 @@ export type ResolvedMattermostAccount = {
blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
};
const {
listAccountIds: listMattermostAccountIds,
resolveDefaultAccountId: resolveDefaultMattermostAccountId,
} = createAccountListHelpers("mattermost");
export { listMattermostAccountIds, resolveDefaultMattermostAccountId };
const mattermostAccountHelpers = createAccountListHelpers("mattermost");
export function listMattermostAccountIds(cfg: OpenClawConfig): string[] {
return mattermostAccountHelpers.listAccountIds(cfg);
}
export function resolveDefaultMattermostAccountId(cfg: OpenClawConfig): string {
return mattermostAccountHelpers.resolveDefaultAccountId(cfg);
}
function mergeMattermostAccountConfig(
cfg: OpenClawConfig,
@@ -1,4 +1,3 @@
import type { ChannelDirectoryEntry, OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
@@ -7,6 +6,7 @@ import {
type MattermostClient,
type MattermostUser,
} from "./client.js";
import type { ChannelDirectoryEntry, OpenClawConfig, RuntimeEnv } from "./runtime-api.js";
export type MattermostDirectoryParams = {
cfg: OpenClawConfig;
@@ -1,8 +1,8 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { isTrustedProxyAddress, resolveClientIp, type OpenClawConfig } from "../runtime-api.js";
import { getMattermostRuntime } from "../runtime.js";
import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js";
import { isTrustedProxyAddress, resolveClientIp, type OpenClawConfig } from "./runtime-api.js";
const INTERACTION_MAX_BODY_BYTES = 64 * 1024;
const INTERACTION_BODY_TIMEOUT_MS = 10_000;
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import type { MattermostInteractiveButtonInput } from "./interactions.js";
import {
loadSessionStore,
normalizeProviderId,
@@ -6,8 +7,7 @@ import {
resolveStoredModelOverride,
type ModelsProviderData,
type OpenClawConfig,
} from "../runtime-api.js";
import type { MattermostInteractiveButtonInput } from "./interactions.js";
} from "./runtime-api.js";
const MATTERMOST_MODEL_PICKER_CONTEXT_KEY = "oc_model_picker";
const MODELS_PAGE_SIZE = 8;
@@ -6,13 +6,17 @@ const resolveAllowlistMatchSimple = vi.hoisted(() => vi.fn());
const resolveControlCommandGate = vi.hoisted(() => vi.fn());
const resolveEffectiveAllowFromLists = vi.hoisted(() => vi.fn());
vi.mock("../runtime-api.js", () => ({
evaluateSenderGroupAccessForPolicy,
isDangerousNameMatchingEnabled,
resolveAllowlistMatchSimple,
resolveControlCommandGate,
resolveEffectiveAllowFromLists,
}));
vi.mock("./runtime-api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./runtime-api.js")>();
return {
...actual,
evaluateSenderGroupAccessForPolicy,
isDangerousNameMatchingEnabled,
resolveAllowlistMatchSimple,
resolveControlCommandGate,
resolveEffectiveAllowFromLists,
};
});
describe("mattermost monitor auth", () => {
beforeEach(() => {
@@ -1,13 +1,13 @@
import type { OpenClawConfig } from "../runtime-api.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import type { MattermostChannel } from "./client.js";
import type { OpenClawConfig } from "./runtime-api.js";
import {
evaluateSenderGroupAccessForPolicy,
isDangerousNameMatchingEnabled,
resolveAllowlistMatchSimple,
resolveControlCommandGate,
resolveEffectiveAllowFromLists,
} from "../runtime-api.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import type { MattermostChannel } from "./client.js";
} from "./runtime-api.js";
export function normalizeMattermostAllowEntry(entry: string): string {
const trimmed = entry.trim();
@@ -1,4 +1,4 @@
import type { ChatType, OpenClawConfig } from "../runtime-api.js";
import type { ChatType, OpenClawConfig } from "./runtime-api.js";
export function mapMattermostChannelTypeToChatType(channelType?: string | null): ChatType {
if (!channelType) {
@@ -1,9 +1,12 @@
import {
createDedupeCache,
formatInboundFromLabel as formatInboundFromLabelShared,
rawDataToString,
resolveThreadSessionKeys as resolveThreadSessionKeysShared,
type OpenClawConfig,
} from "../runtime-api.js";
export { createDedupeCache, rawDataToString } from "../runtime-api.js";
} from "./runtime-api.js";
export { createDedupeCache, rawDataToString };
export type ResponsePrefixContext = {
model?: string;
@@ -1,15 +1,15 @@
import {
listSkillCommandsForAgents,
parseStrictPositiveInteger,
type OpenClawConfig,
type RuntimeEnv,
} from "../runtime-api.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import {
fetchMattermostUserTeams,
normalizeMattermostBaseUrl,
type MattermostClient,
} from "./client.js";
import {
listSkillCommandsForAgents,
parseStrictPositiveInteger,
type OpenClawConfig,
type RuntimeEnv,
} from "./runtime-api.js";
import {
DEFAULT_COMMAND_SPECS,
isSlashCommandsEnabled,
@@ -1,9 +1,9 @@
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
import { z } from "openclaw/plugin-sdk/zod";
import WebSocket from "ws";
import type { ChannelAccountSnapshot, RuntimeEnv } from "../runtime-api.js";
import { MattermostPostSchema, type MattermostPost } from "./client.js";
import { rawDataToString } from "./monitor-helpers.js";
import type { ChannelAccountSnapshot, RuntimeEnv } from "./runtime-api.js";
export type MattermostEventPayload = {
event?: string;
+30 -30
View File
@@ -1,33 +1,3 @@
import type {
ChannelAccountSnapshot,
ChatType,
OpenClawConfig,
ReplyPayload,
RuntimeEnv,
} from "../runtime-api.js";
import {
buildAgentMediaPayload,
buildModelsProviderData,
DM_GROUP_ACCESS_REASON,
createChannelPairingController,
createChannelReplyPipeline,
logInboundDrop,
logTypingFailure,
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntryIfEnabled,
isDangerousNameMatchingEnabled,
registerPluginHttpRoute,
resolveControlCommandGate,
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveChannelMediaMaxBytes,
warnMissingProviderGroupPolicyFallbackOnce,
type HistoryEntry,
} from "../runtime-api.js";
import { getMattermostRuntime } from "../runtime.js";
import { resolveMattermostAccount, resolveMattermostReplyToMode } from "./accounts.js";
import {
@@ -82,6 +52,36 @@ import {
} from "./monitor-websocket.js";
import { runWithReconnect } from "./reconnect.js";
import { deliverMattermostReplyPayload } from "./reply-delivery.js";
import type {
ChannelAccountSnapshot,
ChatType,
OpenClawConfig,
ReplyPayload,
RuntimeEnv,
} from "./runtime-api.js";
import {
buildAgentMediaPayload,
buildModelsProviderData,
DM_GROUP_ACCESS_REASON,
createChannelPairingController,
createChannelReplyPipeline,
logInboundDrop,
logTypingFailure,
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntryIfEnabled,
isDangerousNameMatchingEnabled,
registerPluginHttpRoute,
resolveControlCommandGate,
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveChannelMediaMaxBytes,
warnMissingProviderGroupPolicyFallbackOnce,
type HistoryEntry,
} from "./runtime-api.js";
import { sendMessageMattermost } from "./send.js";
import { cleanupSlashCommands } from "./slash-commands.js";
import { deactivateSlashCommands, getSlashCommandState } from "./slash-state.js";
@@ -1,5 +1,5 @@
import type { BaseProbeResult } from "../runtime-api.js";
import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js";
import type { BaseProbeResult } from "./runtime-api.js";
export type MattermostProbe = BaseProbeResult & {
status?: number | null;
@@ -1,4 +1,3 @@
import type { OpenClawConfig } from "../runtime-api.js";
import { resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
@@ -6,6 +5,7 @@ import {
type MattermostClient,
type MattermostFetch,
} from "./client.js";
import type { OpenClawConfig } from "./runtime-api.js";
type Result = { ok: true } | { ok: false; error: string };
type ReactionParams = {
@@ -2,8 +2,12 @@ import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "../runtime-api.js";
import { getAgentScopedMediaLocalRoots } from "../runtime-api.js";
import {
getAgentScopedMediaLocalRoots,
type OpenClawConfig,
type PluginRuntime,
type ReplyPayload,
} from "./runtime-api.js";
type MarkdownTableMode = Parameters<PluginRuntime["channel"]["text"]["convertMarkdownTables"]>[1];
+1 -1
View File
@@ -1,6 +1,5 @@
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
import { loadOutboundMediaFromUrl, type OpenClawConfig } from "../runtime-api.js";
import { getMattermostRuntime } from "../runtime.js";
import { resolveMattermostAccount } from "./accounts.js";
import {
@@ -22,6 +21,7 @@ import {
setInteractionSecret,
type MattermostInteractiveButtonInput,
} from "./interactions.js";
import { loadOutboundMediaFromUrl, type OpenClawConfig } from "./runtime-api.js";
import { isMattermostId, resolveMattermostOpaqueTarget } from "./target-resolution.js";
export type MattermostSendOpts = {
@@ -39,14 +39,29 @@ const mockState = vi.hoisted(() => ({
normalizeMattermostAllowList: vi.fn((value: unknown) => value),
}));
vi.mock("openclaw/plugin-sdk/mattermost", () => ({
buildModelsProviderData: mockState.buildModelsProviderData,
createReplyPrefixOptions: vi.fn(() => ({})),
createTypingCallbacks: vi.fn(() => ({ onReplyStart: vi.fn() })),
isRequestBodyLimitError: vi.fn(() => false),
logTypingFailure: vi.fn(),
readRequestBodyWithLimit: mockState.readRequestBodyWithLimit,
}));
vi.mock("./runtime-api.js", () => {
return {
buildModelsProviderData: mockState.buildModelsProviderData,
createChannelReplyPipeline: vi.fn(() => ({
onModelSelected: vi.fn(),
typingCallbacks: {},
})),
createDedupeCache: vi.fn(() => ({
check: () => false,
})),
createReplyPrefixOptions: vi.fn(() => ({})),
createTypingCallbacks: vi.fn(() => ({ onReplyStart: vi.fn() })),
isRequestBodyLimitError: vi.fn(() => false),
logTypingFailure: vi.fn(),
formatInboundFromLabel: vi.fn(() => ""),
rawDataToString: vi.fn((value: unknown) => String(value ?? "")),
readRequestBodyWithLimit: mockState.readRequestBodyWithLimit,
resolveThreadSessionKeys: vi.fn((params: { baseSessionKey: string }) => ({
sessionKey: params.baseSessionKey,
parentSessionKey: undefined,
})),
};
});
vi.mock("../runtime.js", () => ({
getMattermostRuntime: () => ({
@@ -71,12 +86,16 @@ vi.mock("../runtime.js", () => ({
}),
}));
vi.mock("./client.js", () => ({
createMattermostClient: mockState.createMattermostClient,
fetchMattermostChannel: mockState.fetchMattermostChannel,
normalizeMattermostBaseUrl: vi.fn((value: string | undefined) => value?.trim() ?? ""),
sendMattermostTyping: vi.fn(),
}));
vi.mock("./client.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./client.js")>();
return {
...actual,
createMattermostClient: mockState.createMattermostClient,
fetchMattermostChannel: mockState.fetchMattermostChannel,
normalizeMattermostBaseUrl: vi.fn((value: string | undefined) => value?.trim() ?? ""),
sendMattermostTyping: vi.fn(),
};
});
vi.mock("./model-picker.js", () => ({
renderMattermostModelSummaryView: vi.fn(),
@@ -7,16 +7,6 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ResolvedMattermostAccount } from "../mattermost/accounts.js";
import {
buildModelsProviderData,
createChannelReplyPipeline,
isRequestBodyLimitError,
logTypingFailure,
readRequestBodyWithLimit,
type OpenClawConfig,
type ReplyPayload,
type RuntimeEnv,
} from "../runtime-api.js";
import { getMattermostRuntime } from "../runtime.js";
import {
createMattermostClient,
@@ -37,6 +27,16 @@ import {
normalizeMattermostAllowList,
} from "./monitor-auth.js";
import { deliverMattermostReplyPayload } from "./reply-delivery.js";
import {
buildModelsProviderData,
createChannelReplyPipeline,
isRequestBodyLimitError,
logTypingFailure,
readRequestBodyWithLimit,
type OpenClawConfig,
type ReplyPayload,
type RuntimeEnv,
} from "./runtime-api.js";
import { sendMessageMattermost } from "./send.js";
import {
parseSlashCommandPayload,
@@ -10,9 +10,9 @@
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawPluginApi } from "../runtime-api.js";
import type { MattermostConfig } from "../types.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import type { OpenClawPluginApi } from "./runtime-api.js";
import { resolveSlashCommandConfig, type MattermostRegisteredCommand } from "./slash-commands.js";
import { createSlashCommandHttpHandler } from "./slash-http.js";
@@ -87,8 +87,8 @@ export function activateSlashCommands(params: {
registeredCommands: MattermostRegisteredCommand[];
triggerMap?: Map<string, string>;
api: {
cfg: import("../runtime-api.js").OpenClawConfig;
runtime: import("../runtime-api.js").RuntimeEnv;
cfg: import("./runtime-api.js").OpenClawConfig;
runtime: import("./runtime-api.js").RuntimeEnv;
};
log?: (msg: string) => void;
}) {
@@ -1,10 +1,10 @@
import type { OpenClawConfig } from "../runtime-api.js";
import { resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
fetchMattermostUser,
normalizeMattermostBaseUrl,
} from "./client.js";
import type { OpenClawConfig } from "./runtime-api.js";
export type MattermostOpaqueTargetResolution = {
kind: "user" | "channel";
@@ -1,6 +1,6 @@
{
"name": "@openclaw/media-understanding-core",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw media understanding runtime package",
"type": "module"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-core",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"private": true,
"description": "OpenClaw core memory search plugin",
"type": "module",
@@ -8,7 +8,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.28-beta.1"
"openclaw": ">=2026.3.28"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -0,0 +1,365 @@
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Mock } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { logWarnMock, logDebugMock, logInfoMock } = vi.hoisted(() => ({
logWarnMock: vi.fn(),
logDebugMock: vi.fn(),
logInfoMock: vi.fn(),
}));
type MockChild = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: (signal?: NodeJS.Signals) => void;
closeWith: (code?: number | null) => void;
};
function createMockChild(params?: { autoClose?: boolean }): MockChild {
const stdout = new EventEmitter();
const stderr = new EventEmitter();
const child = new EventEmitter() as MockChild;
child.stdout = stdout;
child.stderr = stderr;
child.closeWith = (code = 0) => {
child.emit("close", code);
};
child.kill = () => {};
if (params?.autoClose !== false) {
queueMicrotask(() => {
child.emit("close", 0);
});
}
return child;
}
function emitAndClose(
child: MockChild,
stream: "stdout" | "stderr",
data: string,
code: number = 0,
) {
queueMicrotask(() => {
child[stream].emit("data", data);
child.closeWith(code);
});
}
vi.mock("openclaw/plugin-sdk/memory-core-host-engine-foundation", async () => {
const actual = await vi.importActual<
typeof import("openclaw/plugin-sdk/memory-core-host-engine-foundation")
>("openclaw/plugin-sdk/memory-core-host-engine-foundation");
return {
...actual,
createSubsystemLogger: () => {
const logger = {
warn: logWarnMock,
debug: logDebugMock,
info: logInfoMock,
child: () => logger,
};
return logger;
},
};
});
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return {
...actual,
spawn: vi.fn(),
};
});
import { spawn as mockedSpawn } from "node:child_process";
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
import { resolveMemoryBackendConfig } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { QmdMemoryManager } from "./qmd-manager.js";
const spawnMock = mockedSpawn as unknown as Mock;
describe("QmdMemoryManager slugified path resolution", () => {
let tmpRoot: string;
let workspaceDir: string;
let stateDir: string;
let cfg: OpenClawConfig;
const agentId = "main";
const openManagers = new Set<QmdMemoryManager>();
function trackManager<T extends QmdMemoryManager | null>(manager: T): T {
if (manager) {
openManagers.add(manager);
}
return manager;
}
async function createManager(params?: { cfg?: OpenClawConfig }) {
const cfgToUse = params?.cfg ?? cfg;
const resolved = resolveMemoryBackendConfig({ cfg: cfgToUse, agentId });
const manager = trackManager(
await QmdMemoryManager.create({
cfg: cfgToUse,
agentId,
resolved,
mode: "status",
}),
);
if (!manager) {
throw new Error("manager missing");
}
return { manager };
}
function installIndexedPathStub(params: {
manager: QmdMemoryManager;
collection: string;
normalizedPath: string;
actualPath?: string;
exactPaths?: string[];
allPaths?: string[];
}) {
const inner = params.manager as unknown as {
db: {
prepare: (query: string) => { all: (...args: unknown[]) => unknown };
close: () => void;
};
};
inner.db = {
prepare: (query: string) => ({
all: (...args: unknown[]) => {
if (query.includes("collection = ? AND path = ?")) {
expect(args).toEqual([params.collection, params.normalizedPath]);
return (params.exactPaths ?? []).map((pathValue) => ({ path: pathValue }));
}
if (query.includes("collection = ? AND active = 1")) {
expect(args).toEqual([params.collection]);
return (params.allPaths ?? [params.actualPath]).map((pathValue) => ({
path: pathValue,
}));
}
throw new Error(`unexpected sqlite query: ${query}`);
},
}),
close: () => {},
};
}
beforeEach(async () => {
spawnMock.mockReset();
spawnMock.mockImplementation(() => createMockChild());
logWarnMock.mockClear();
logDebugMock.mockClear();
logInfoMock.mockClear();
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-slugified-"));
workspaceDir = path.join(tmpRoot, "workspace");
stateDir = path.join(tmpRoot, "state");
await fs.mkdir(workspaceDir, { recursive: true });
process.env.OPENCLAW_STATE_DIR = stateDir;
cfg = {
agents: {
list: [{ id: agentId, default: true, workspace: workspaceDir }],
},
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
});
afterEach(async () => {
await Promise.all(
Array.from(openManagers, async (manager) => {
await manager.close();
}),
);
openManagers.clear();
await fs.rm(tmpRoot, { recursive: true, force: true });
delete process.env.OPENCLAW_STATE_DIR;
});
it("maps slugified workspace qmd URIs back to the indexed filesystem path", async () => {
const actualRelative = "extra-docs/Category/Sub Category/Topic Name/Topic Name.md";
const actualFile = path.join(workspaceDir, actualRelative);
await fs.mkdir(path.dirname(actualFile), { recursive: true });
await fs.writeFile(actualFile, "line-1\nline-2\nline-3", "utf-8");
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
emitAndClose(
child,
"stdout",
JSON.stringify([
{
file: "qmd://workspace-main/extra-docs/category/sub-category/topic-name/topic-name.md",
score: 0.73,
snippet: "@@ -2,1\nline-2",
},
]),
);
return child;
}
return createMockChild();
});
const { manager } = await createManager();
installIndexedPathStub({
manager,
collection: "workspace-main",
normalizedPath: "extra-docs/category/sub-category/topic-name/topic-name.md",
actualPath: actualRelative,
});
const results = await manager.search("line-2", {
sessionKey: "agent:main:slack:dm:u123",
});
expect(results).toEqual([
{
path: actualRelative,
startLine: 2,
endLine: 2,
score: 0.73,
snippet: "@@ -2,1\nline-2",
source: "memory",
},
]);
await expect(manager.readFile({ relPath: results[0]!.path })).resolves.toEqual({
path: actualRelative,
text: "line-1\nline-2\nline-3",
});
});
it("maps slugified extra collection qmd URIs back to qmd/<collection>/ paths", async () => {
const extraRoot = path.join(tmpRoot, "vault");
await fs.mkdir(extraRoot, { recursive: true });
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [{ path: extraRoot, pattern: "**/*.md", name: "vault" }],
},
},
} as OpenClawConfig;
const actualRelative = "Topics/Sub Category/Topic Name.md";
const actualFile = path.join(extraRoot, actualRelative);
await fs.mkdir(path.dirname(actualFile), { recursive: true });
await fs.writeFile(actualFile, "vault memory", "utf-8");
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
emitAndClose(
child,
"stdout",
JSON.stringify([
{
file: "qmd://vault-main/topics/sub-category/topic-name.md",
score: 0.81,
snippet: "@@ -1,1\nvault memory",
},
]),
);
return child;
}
return createMockChild();
});
const { manager } = await createManager({ cfg });
installIndexedPathStub({
manager,
collection: "vault-main",
normalizedPath: "topics/sub-category/topic-name.md",
actualPath: actualRelative,
});
const results = await manager.search("vault memory", {
sessionKey: "agent:main:slack:dm:u123",
});
expect(results).toEqual([
{
path: `qmd/vault-main/${actualRelative}`,
startLine: 1,
endLine: 1,
score: 0.81,
snippet: "@@ -1,1\nvault memory",
source: "memory",
},
]);
await expect(manager.readFile({ relPath: results[0]!.path })).resolves.toEqual({
path: `qmd/vault-main/${actualRelative}`,
text: "vault memory",
});
});
it("prefers an exact indexed path over normalized slug recovery", async () => {
const exactRelative = "notes/topic-name.md";
const slugCollisionRelative = "notes/Topic Name.md";
const exactFile = path.join(workspaceDir, exactRelative);
const collisionFile = path.join(workspaceDir, slugCollisionRelative);
await fs.mkdir(path.dirname(exactFile), { recursive: true });
await fs.writeFile(exactFile, "exact slugified path", "utf-8");
await fs.writeFile(collisionFile, "mixed case path", "utf-8");
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
emitAndClose(
child,
"stdout",
JSON.stringify([
{
file: "qmd://workspace-main/notes/topic-name.md",
score: 0.79,
snippet: "@@ -1,1\nexact slugified path",
},
]),
);
return child;
}
return createMockChild();
});
const { manager } = await createManager();
installIndexedPathStub({
manager,
collection: "workspace-main",
normalizedPath: exactRelative,
exactPaths: [exactRelative],
allPaths: [exactRelative, slugCollisionRelative],
});
const results = await manager.search("exact slugified path", {
sessionKey: "agent:main:slack:dm:u123",
});
expect(results).toEqual([
{
path: exactRelative,
startLine: 1,
endLine: 1,
score: 0.79,
snippet: "@@ -1,1\nexact slugified path",
source: "memory",
},
]);
await expect(manager.readFile({ relPath: results[0]!.path })).resolves.toEqual({
path: exactRelative,
text: "exact slugified path",
});
});
});
@@ -2025,6 +2025,140 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("runs periodic embed maintenance even when regular update scheduling is disabled", async () => {
vi.useFakeTimers();
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "query",
update: {
interval: "0s",
debounceMs: 0,
onBoot: false,
embedInterval: "5m",
},
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
const { manager } = await createManager({ mode: "full" });
const commandCallsBefore = spawnMock.mock.calls.filter((call: unknown[]) => {
const args = call[1] as string[];
return args[0] === "update" || args[0] === "embed";
});
expect(commandCallsBefore).toHaveLength(0);
await vi.advanceTimersByTimeAsync(5 * 60_000);
const commandCalls = spawnMock.mock.calls
.map((call: unknown[]) => call[1] as string[])
.filter((args: string[]) => args[0] === "update" || args[0] === "embed");
expect(commandCalls).toEqual([["update"], ["embed"]]);
await manager.close();
});
it("runs periodic embed maintenance when embed cadence is faster than update cadence", async () => {
vi.useFakeTimers();
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "query",
update: {
interval: "20m",
debounceMs: 0,
onBoot: false,
embedInterval: "5m",
},
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
const { manager } = await createManager({ mode: "full" });
await vi.advanceTimersByTimeAsync(5 * 60_000);
const commandCalls = spawnMock.mock.calls
.map((call: unknown[]) => call[1] as string[])
.filter((args: string[]) => args[0] === "update" || args[0] === "embed");
expect(commandCalls).toEqual([["update"], ["embed"]]);
await manager.close();
});
it("does not schedule redundant embed maintenance when regular updates are already more frequent", async () => {
vi.useFakeTimers();
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "query",
update: {
interval: "5m",
debounceMs: 0,
onBoot: false,
embedInterval: "20m",
},
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
const { manager } = await createManager({ mode: "full" });
await vi.advanceTimersByTimeAsync(6 * 60_000);
const commandCalls = spawnMock.mock.calls
.map((call: unknown[]) => call[1] as string[])
.filter((args: string[]) => args[0] === "update" || args[0] === "embed");
expect(commandCalls).toEqual([["update"], ["embed"]]);
await manager.close();
});
it("does not arm periodic embed maintenance in search mode", async () => {
vi.useFakeTimers();
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "search",
update: {
interval: "0s",
debounceMs: 0,
onBoot: false,
embedInterval: "5m",
},
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
const { manager } = await createManager({ mode: "full" });
await vi.advanceTimersByTimeAsync(5 * 60_000);
const commandCalls = spawnMock.mock.calls
.map((call: unknown[]) => call[1] as string[])
.filter((args: string[]) => args[0] === "update" || args[0] === "embed");
expect(commandCalls).toEqual([]);
await manager.close();
});
it("skips qmd embed in search mode even for forced sync", async () => {
cfg = {
...cfg,
@@ -184,6 +184,7 @@ export class QmdMemoryManager implements MemorySearchManager {
private readonly maxQmdOutputChars = MAX_QMD_OUTPUT_CHARS;
private readonly sessionExporter: SessionExporterConfig | null;
private updateTimer: NodeJS.Timeout | null = null;
private embedTimer: NodeJS.Timeout | null = null;
private pendingUpdate: Promise<void> | null = null;
private queuedForcedUpdate: Promise<void> | null = null;
private queuedForcedRuns = 0;
@@ -290,6 +291,13 @@ export class QmdMemoryManager implements MemorySearchManager {
});
}, this.qmd.update.intervalMs);
}
if (this.shouldScheduleEmbedTimer()) {
this.embedTimer = setInterval(() => {
void this.runUpdate("embed-interval").catch((err) => {
log.warn(`qmd embed interval update failed (${String(err)})`);
});
}, this.qmd.update.embedIntervalMs);
}
}
private bootstrapCollections(): void {
@@ -985,6 +993,10 @@ export class QmdMemoryManager implements MemorySearchManager {
clearInterval(this.updateTimer);
this.updateTimer = null;
}
if (this.embedTimer) {
clearInterval(this.embedTimer);
this.embedTimer = null;
}
this.queuedForcedRuns = 0;
await this.pendingUpdate?.catch(() => undefined);
await this.queuedForcedUpdate?.catch(() => undefined);
@@ -1111,6 +1123,18 @@ export class QmdMemoryManager implements MemorySearchManager {
);
}
private shouldScheduleEmbedTimer(): boolean {
if (this.qmd.searchMode === "search") {
return false;
}
const embedIntervalMs = this.qmd.update.embedIntervalMs;
if (embedIntervalMs <= 0) {
return false;
}
const updateIntervalMs = this.qmd.update.intervalMs;
return updateIntervalMs <= 0 || updateIntervalMs > embedIntervalMs;
}
private noteEmbedFailure(reason: string, err: unknown): void {
this.embedFailureCount += 1;
const delayMs = Math.min(
@@ -1548,6 +1572,13 @@ export class QmdMemoryManager implements MemorySearchManager {
if (!hints.preferredCollection || !hints.preferredFile) {
return null;
}
const indexedLocation = this.resolveIndexedDocLocationFromHint(
hints.preferredCollection,
hints.preferredFile,
);
if (indexedLocation) {
return indexedLocation;
}
const collectionRelativePath = this.toCollectionRelativePath(
hints.preferredCollection,
hints.preferredFile,
@@ -1558,6 +1589,45 @@ export class QmdMemoryManager implements MemorySearchManager {
return this.toDocLocation(hints.preferredCollection, collectionRelativePath);
}
private resolveIndexedDocLocationFromHint(
collection: string,
preferredFile: string,
): { rel: string; abs: string; source: MemorySource } | null {
const trimmedCollection = collection.trim();
const trimmedFile = preferredFile.trim();
if (!trimmedCollection || !trimmedFile) {
return null;
}
const exactPath = path.normalize(trimmedFile).replace(/\\/g, "/");
let rows: Array<{ path: string }> = [];
try {
const db = this.ensureDb();
const exactRows = db
.prepare("SELECT path FROM documents WHERE collection = ? AND path = ? AND active = 1")
.all(trimmedCollection, exactPath) as Array<{ path: string }>;
if (exactRows.length > 0) {
return this.toDocLocation(trimmedCollection, exactRows[0].path);
}
rows = db
.prepare("SELECT path FROM documents WHERE collection = ? AND active = 1")
.all(trimmedCollection) as Array<{ path: string }>;
} catch (err) {
if (this.isSqliteBusyError(err)) {
log.debug(`qmd index is busy while resolving hinted path: ${String(err)}`);
throw this.createQmdBusyError(err);
}
// Hint-based lookup is best effort. Fall back to the raw hinted path when
// the index is unavailable or still warming.
log.debug(`qmd index hint lookup skipped: ${String(err)}`);
return null;
}
const matches = rows.filter((row) => this.matchesPreferredFileHint(row.path, trimmedFile));
if (matches.length !== 1) {
return null;
}
return this.toDocLocation(trimmedCollection, matches[0].path);
}
private normalizeDocHints(hints?: { preferredCollection?: string; preferredFile?: string }): {
preferredCollection?: string;
preferredFile?: string;
@@ -1637,10 +1707,8 @@ export class QmdMemoryManager implements MemorySearchManager {
}
}
if (hints?.preferredFile) {
const preferred = path.normalize(hints.preferredFile);
for (const row of rows) {
const rowPath = path.normalize(row.path);
if (rowPath !== preferred && !rowPath.endsWith(path.sep + preferred)) {
if (!this.matchesPreferredFileHint(row.path, hints.preferredFile)) {
continue;
}
const location = this.toDocLocation(row.collection, row.path);
@@ -1658,6 +1726,58 @@ export class QmdMemoryManager implements MemorySearchManager {
return null;
}
private matchesPreferredFileHint(rowPath: string, preferredFile: string): boolean {
const preferred = path.normalize(preferredFile).replace(/\\/g, "/");
const normalizedRowPath = path.normalize(rowPath).replace(/\\/g, "/");
if (normalizedRowPath === preferred || normalizedRowPath.endsWith(`/${preferred}`)) {
return true;
}
const normalizedPreferredLookup = this.normalizeQmdLookupPath(preferredFile);
if (!normalizedPreferredLookup) {
return false;
}
const normalizedRowLookup = this.normalizeQmdLookupPath(rowPath);
return (
normalizedRowLookup === normalizedPreferredLookup ||
normalizedRowLookup.endsWith(`/${normalizedPreferredLookup}`)
);
}
private normalizeQmdLookupPath(filePath: string): string {
return filePath
.replace(/\\/g, "/")
.split("/")
.filter((segment) => segment.length > 0 && segment !== ".")
.map((segment) => this.normalizeQmdLookupSegment(segment))
.filter(Boolean)
.join("/");
}
private normalizeQmdLookupSegment(segment: string): string {
const trimmed = segment.trim();
if (!trimmed) {
return "";
}
if (trimmed === "." || trimmed === "..") {
return trimmed;
}
const parsed = path.posix.parse(trimmed);
const normalizePart = (value: string): string =>
value
.normalize("NFKD")
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "");
const normalizedName = normalizePart(parsed.name);
const normalizedExt = parsed.ext
.normalize("NFKD")
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}.]+/gu, "");
const fallbackName = parsed.name.normalize("NFKD").toLowerCase().replace(/\s+/g, "-").trim();
return `${normalizedName || fallbackName || "file"}${normalizedExt}`;
}
private extractSnippetLines(snippet: string): { startLine: number; endLine: number } {
const match = SNIPPET_HEADER_RE.exec(snippet);
if (match) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-lancedb",
"version": "2026.3.28-beta.1",
"version": "2026.3.28",
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
"type": "module",
"dependencies": {

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