diff --git a/.secrets.baseline b/.secrets.baseline
index 5a0c639b9e3..056b2dd8778 100644
--- a/.secrets.baseline
+++ b/.secrets.baseline
@@ -12991,7 +12991,7 @@
"filename": "ui/src/i18n/locales/en.ts",
"hashed_secret": "de0ff6b974d6910aca8d6b830e1b761f076d8fe6",
"is_verified": false,
- "line_number": 61
+ "line_number": 74
}
],
"ui/src/i18n/locales/pt-BR.ts": [
@@ -13000,7 +13000,7 @@
"filename": "ui/src/i18n/locales/pt-BR.ts",
"hashed_secret": "ef7b6f95faca2d7d3a5aa5a6434c89530c6dd243",
"is_verified": false,
- "line_number": 61
+ "line_number": 73
}
],
"vendor/a2ui/README.md": [
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6983b27cc6e..0f8fff777cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,33 @@ Docs: https://docs.openclaw.ai
### Security
+- Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible `\u{...}` escapes instead of spoofing the reviewed command. (`GHSA-pcqg-f7rg-xfvv`)(#43687) Thanks @EkiXu and @vincentkoc.
+- Security/device pairing: cap issued and verified device-token scopes to each paired device's approved scope baseline so stale or overbroad tokens cannot exceed approved access. (`GHSA-2pwv-x786-56f8`)(#43686) Thanks @tdjackey and @vincentkoc.
+- Security/proxy attachments: restore the shared media-store size cap for persisted browser proxy files so oversized payloads are rejected instead of overriding the intended 5 MB limit. (`GHSA-6rph-mmhp-h7h9`)(#43684) Thanks @tdjackey and @vincentkoc.
+- Security/host env: block inherited `GIT_EXEC_PATH` from sanitized host exec environments so Git helper resolution cannot be steered by host environment state. (`GHSA-jf5v-pqgw-gm5m`)(#43685) Thanks @zpbrent and @vincentkoc.
+- Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via `session_status`. (`GHSA-wcxr-59v9-rxr8`)(#43754) Thanks @tdjackey and @vincentkoc.
+- Models/secrets: enforce source-managed SecretRef markers in generated `models.json` so runtime-resolved provider secrets are not persisted when runtime projection is skipped. (#43759) Thanks @joshavant.
+- Security/browser.request: block persistent browser profile create/delete routes from write-scoped `browser.request` so callers can no longer persist admin-only browser profile changes through the browser control surface. (`GHSA-vmhq-cqm9-6p7q`)(#43800) Thanks @tdjackey and @vincentkoc.
+- Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external `agent` callers can no longer override the gateway workspace boundary. (`GHSA-2rqg-gjgv-84jm`)(#43801) Thanks @tdjackey and @vincentkoc.
+- Security/exec allowlist: preserve POSIX case sensitivity and keep `?` within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (`GHSA-f8r2-vg7x-gh8m`)(#43798) Thanks @zpbrent and @vincentkoc.
+
+### Changes
+
+### Fixes
+
+- Cron/proactive delivery: keep isolated direct cron sends out of the write-ahead resend queue so transient-send retries do not replay duplicate proactive messages after restart. (#40646) Thanks @openperf and @vincentkoc.
+- TUI/chat log: reuse the active assistant message component for the same streaming run so `openclaw tui` no longer renders duplicate assistant replies. (#35364) Thanks @lisitan.
+- macOS/Reminders: add the missing `NSRemindersUsageDescription` to the bundled app so `apple-reminders` can trigger the system permission prompt from OpenClaw.app. (#8559) Thanks @dinakars777.
+- iMessage/self-chat echo dedupe: drop reflected duplicate copies only when a matching `is_from_me` event was just seen for the same chat, text, and `created_at`, preventing self-chat loops without broad text-only suppression. Related to #32166. (#38440) Thanks @vincentkoc.
+- Mattermost/block streaming: fix duplicate message delivery (one threaded, one top-level) when block streaming is active by excluding `replyToId` from the block reply dedup key and adding an explicit `threading` dock to the Mattermost plugin. (#41362) Thanks @mathiasnagler and @vincentkoc.
+- BlueBubbles/self-chat echo dedupe: drop reflected duplicate webhook copies only when a matching `fromMe` event was just seen for the same chat, body, and timestamp, preventing self-chat loops without broad webhook suppression. Related to #32166. (#38442) Thanks @vincentkoc.
+- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
+- Sandbox/write: preserve pinned mutation-helper payload stdin so sandboxed `write` no longer reports success while creating empty files. (#43876) Thanks @glitch418x.
+
+## 2026.3.11
+
+### Security
+
- Gateway/WebSocket: enforce browser origin validation for all browser-originated connections regardless of whether proxy headers are present, closing a cross-site WebSocket hijacking path in `trusted-proxy` mode that could grant untrusted origins `operator.admin` access. (GHSA-5wcw-8jjv-m286)
### Changes
@@ -25,6 +52,7 @@ Docs: https://docs.openclaw.ai
- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky.
- Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle.
- Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc.
+- LLM Task/Lobster: add an optional `thinking` override so workflow calls can explicitly set embedded reasoning level with shared validation for invalid values and unsupported `xhigh` modes. (#15606) Thanks @xadenryan and @ImLukeF.
### Breaking
@@ -44,6 +72,7 @@ Docs: https://docs.openclaw.ai
- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev.
- Telegram/final preview delivery followup: keep ambiguous missing-`message_id` finals only when a preview was already visible, while first-preview/no-id cases still fall back so Telegram users do not lose the final reply. (#41932) thanks @hougangdev.
- Telegram/final preview cleanup follow-up: clear stale cleanup-retain state only for transient preview finals so archived-preview retains no longer leave a stale partial bubble beside a later fallback-sent final. (#41763) Thanks @obviyus.
+- Telegram/poll restarts: scope process-level polling restarts to real Telegram `getUpdates` failures so unrelated network errors, such as Slack DNS misses, no longer bounce Telegram polling. (#43799) Thanks @obviyus.
- Gateway/auth: allow one trusted device-token retry on shared-token mismatch with recovery hints to prevent reconnect churn during token drift. (#42507) Thanks @joshavant.
- Gateway/config errors: surface up to three validation issues in top-level `config.set`, `config.patch`, and `config.apply` error messages while preserving structured issue details. (#42664) Thanks @huntharo.
- Agents/Azure OpenAI Responses: include the `azure-openai` provider in the Responses API store override so Azure OpenAI multi-turn cron jobs and embedded agent runs no longer fail with HTTP 400 "store is set to false". (#42934, fixes #42800) Thanks @ademczuk.
@@ -86,7 +115,6 @@ Docs: https://docs.openclaw.ai
- Security/system.run: fail closed for approval-backed interpreter/runtime commands when OpenClaw cannot bind exactly one concrete local file operand, while extending best-effort direct-file binding to additional runtime forms. Thanks @tdjackey for reporting.
- Gateway/session reset auth: split conversation `/new` and `/reset` handling away from the admin-only `sessions.reset` control-plane RPC so write-scoped gateway callers can no longer reach the privileged reset path through `agent`. Thanks @tdjackey for reporting.
- Security/plugin runtime: stop unauthenticated plugin HTTP routes from inheriting synthetic admin gateway scopes when they call `runtime.subagent.*`, so admin-only methods like `sessions.delete` stay blocked without gateway auth.
-- Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via `session_status`.
- Security/nodes: treat the `nodes` agent tool as owner-only fallback policy so non-owner senders cannot reach paired-node approval or invoke paths through the shared tool set.
- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94.
- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo.
@@ -198,6 +226,7 @@ Docs: https://docs.openclaw.ai
- macOS/browser proxy: serialize non-GET browser proxy request bodies through `AnyCodable.foundationValue` so nested JSON bodies no longer crash the macOS app with `Invalid type in JSON write (__SwiftValue)`. (#43069) Thanks @Effet.
- CLI/skills tables: keep terminal table borders aligned for wide graphemes, use full reported terminal width, and switch a few ambiguous skill icons to Terminal-safe emoji so `openclaw skills` renders more consistently in Terminal.app and iTerm. Thanks @vincentkoc.
- Memory/Gemini: normalize returned Gemini embeddings across direct query, direct batch, and async batch paths so memory search uses consistent vector handling for Gemini too. (#43409) Thanks @gumadeiras.
+- Agents/failover: recognize additional serialized network errno strings plus `EHOSTDOWN` and `EPIPE` structured codes so transient transport failures trigger timeout failover more reliably. (#42830) Thanks @jnMetaCode.
## 2026.3.7
diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts
index 10b70cea283..32306780c72 100644
--- a/apps/android/app/build.gradle.kts
+++ b/apps/android/app/build.gradle.kts
@@ -64,7 +64,7 @@ android {
minSdk = 31
targetSdk = 36
versionCode = 202603110
- versionName = "2026.3.11-beta.1"
+ versionName = "2026.3.11"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
index 2981a60bbf7..932c9fc5e61 100644
--- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
+++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
@@ -17,6 +17,7 @@ enum HostEnvSecurityPolicy {
"BASH_ENV",
"ENV",
"GIT_EXTERNAL_DIFF",
+ "GIT_EXEC_PATH",
"SHELL",
"SHELLOPTS",
"PS4",
diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist
index 8605ee1264d..0bfd45cc97b 100644
--- a/apps/macos/Sources/OpenClaw/Resources/Info.plist
+++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist
@@ -15,7 +15,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2026.3.11-beta.1
+ 2026.3.11
CFBundleVersion
202603110
CFBundleIconFile
@@ -59,6 +59,8 @@
OpenClaw uses speech recognition to detect your Voice Wake trigger phrase.
NSAppleEventsUsageDescription
OpenClaw needs Automation (AppleScript) permission to drive Terminal and other apps for agent actions.
+ NSRemindersUsageDescription
+ OpenClaw can access Reminders when requested by the agent for the apple-reminders skill.
NSAppTransportSecurity
diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
index ea85e6c1511..b743060f6c0 100644
--- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
@@ -538,8 +538,6 @@ public struct AgentParams: Codable, Sendable {
public let inputprovenance: [String: AnyCodable]?
public let idempotencykey: String
public let label: String?
- public let spawnedby: String?
- public let workspacedir: String?
public init(
message: String,
@@ -566,9 +564,7 @@ public struct AgentParams: Codable, Sendable {
internalevents: [[String: AnyCodable]]?,
inputprovenance: [String: AnyCodable]?,
idempotencykey: String,
- label: String?,
- spawnedby: String?,
- workspacedir: String?)
+ label: String?)
{
self.message = message
self.agentid = agentid
@@ -595,8 +591,6 @@ public struct AgentParams: Codable, Sendable {
self.inputprovenance = inputprovenance
self.idempotencykey = idempotencykey
self.label = label
- self.spawnedby = spawnedby
- self.workspacedir = workspacedir
}
private enum CodingKeys: String, CodingKey {
@@ -625,8 +619,6 @@ public struct AgentParams: Codable, Sendable {
case inputprovenance = "inputProvenance"
case idempotencykey = "idempotencyKey"
case label
- case spawnedby = "spawnedBy"
- case workspacedir = "workspaceDir"
}
}
@@ -1336,6 +1328,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let execnode: AnyCodable?
public let model: AnyCodable?
public let spawnedby: AnyCodable?
+ public let spawnedworkspacedir: AnyCodable?
public let spawndepth: AnyCodable?
public let subagentrole: AnyCodable?
public let subagentcontrolscope: AnyCodable?
@@ -1356,6 +1349,7 @@ public struct SessionsPatchParams: Codable, Sendable {
execnode: AnyCodable?,
model: AnyCodable?,
spawnedby: AnyCodable?,
+ spawnedworkspacedir: AnyCodable?,
spawndepth: AnyCodable?,
subagentrole: AnyCodable?,
subagentcontrolscope: AnyCodable?,
@@ -1375,6 +1369,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.execnode = execnode
self.model = model
self.spawnedby = spawnedby
+ self.spawnedworkspacedir = spawnedworkspacedir
self.spawndepth = spawndepth
self.subagentrole = subagentrole
self.subagentcontrolscope = subagentcontrolscope
@@ -1396,6 +1391,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case execnode = "execNode"
case model
case spawnedby = "spawnedBy"
+ case spawnedworkspacedir = "spawnedWorkspaceDir"
case spawndepth = "spawnDepth"
case subagentrole = "subagentRole"
case subagentcontrolscope = "subagentControlScope"
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
index ea85e6c1511..b743060f6c0 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
@@ -538,8 +538,6 @@ public struct AgentParams: Codable, Sendable {
public let inputprovenance: [String: AnyCodable]?
public let idempotencykey: String
public let label: String?
- public let spawnedby: String?
- public let workspacedir: String?
public init(
message: String,
@@ -566,9 +564,7 @@ public struct AgentParams: Codable, Sendable {
internalevents: [[String: AnyCodable]]?,
inputprovenance: [String: AnyCodable]?,
idempotencykey: String,
- label: String?,
- spawnedby: String?,
- workspacedir: String?)
+ label: String?)
{
self.message = message
self.agentid = agentid
@@ -595,8 +591,6 @@ public struct AgentParams: Codable, Sendable {
self.inputprovenance = inputprovenance
self.idempotencykey = idempotencykey
self.label = label
- self.spawnedby = spawnedby
- self.workspacedir = workspacedir
}
private enum CodingKeys: String, CodingKey {
@@ -625,8 +619,6 @@ public struct AgentParams: Codable, Sendable {
case inputprovenance = "inputProvenance"
case idempotencykey = "idempotencyKey"
case label
- case spawnedby = "spawnedBy"
- case workspacedir = "workspaceDir"
}
}
@@ -1336,6 +1328,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let execnode: AnyCodable?
public let model: AnyCodable?
public let spawnedby: AnyCodable?
+ public let spawnedworkspacedir: AnyCodable?
public let spawndepth: AnyCodable?
public let subagentrole: AnyCodable?
public let subagentcontrolscope: AnyCodable?
@@ -1356,6 +1349,7 @@ public struct SessionsPatchParams: Codable, Sendable {
execnode: AnyCodable?,
model: AnyCodable?,
spawnedby: AnyCodable?,
+ spawnedworkspacedir: AnyCodable?,
spawndepth: AnyCodable?,
subagentrole: AnyCodable?,
subagentcontrolscope: AnyCodable?,
@@ -1375,6 +1369,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.execnode = execnode
self.model = model
self.spawnedby = spawnedby
+ self.spawnedworkspacedir = spawnedworkspacedir
self.spawndepth = spawndepth
self.subagentrole = subagentrole
self.subagentcontrolscope = subagentcontrolscope
@@ -1396,6 +1391,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case execnode = "execNode"
case model
case spawnedby = "spawnedBy"
+ case spawnedworkspacedir = "spawnedWorkspaceDir"
case spawndepth = "spawnDepth"
case subagentrole = "subagentRole"
case subagentcontrolscope = "subagentControlScope"
diff --git a/docs/cli/agent.md b/docs/cli/agent.md
index 93c8d04b41a..430bdf50743 100644
--- a/docs/cli/agent.md
+++ b/docs/cli/agent.md
@@ -25,4 +25,5 @@ openclaw agent --agent ops --message "Generate report" --deliver --reply-channel
## Notes
-- When this command triggers `models.json` regeneration, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names or `secretref-managed`), not resolved secret plaintext.
+- When this command triggers `models.json` regeneration, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names, `secretref-env:ENV_VAR_NAME`, or `secretref-managed`), not resolved secret plaintext.
+- Marker writes are source-authoritative: OpenClaw persists markers from the active source config snapshot, not from resolved runtime secret values.
diff --git a/docs/concepts/models.md b/docs/concepts/models.md
index f87eead821c..6323feef04e 100644
--- a/docs/concepts/models.md
+++ b/docs/concepts/models.md
@@ -207,7 +207,7 @@ mode, pass `--yes` to accept defaults.
## Models registry (`models.json`)
Custom providers in `models.providers` are written into `models.json` under the
-agent directory (default `~/.openclaw/agents//models.json`). This file
+agent directory (default `~/.openclaw/agents//agent/models.json`). This file
is merged by default unless `models.mode` is set to `replace`.
Merge mode precedence for matching provider IDs:
@@ -215,7 +215,9 @@ Merge mode precedence for matching provider IDs:
- Non-empty `baseUrl` already present in the agent `models.json` wins.
- Non-empty `apiKey` in the agent `models.json` wins only when that provider is not SecretRef-managed in current config/auth-profile context.
- SecretRef-managed provider `apiKey` values are refreshed from source markers (`ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs) instead of persisting resolved secrets.
+- SecretRef-managed provider header values are refreshed from source markers (`secretref-env:ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs).
- Empty or missing agent `apiKey`/`baseUrl` fall back to config `models.providers`.
- Other provider fields are refreshed from config and normalized catalog data.
-This marker-based persistence applies whenever OpenClaw regenerates `models.json`, including command-driven paths like `openclaw agent`.
+Marker persistence is source-authoritative: OpenClaw writes markers from the active source config snapshot (pre-resolution), not from resolved runtime secret values.
+This applies whenever OpenClaw regenerates `models.json`, including command-driven paths like `openclaw agent`.
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index 1e48f69d6f8..db5077aebcf 100644
--- a/docs/gateway/configuration-reference.md
+++ b/docs/gateway/configuration-reference.md
@@ -2014,9 +2014,11 @@ OpenClaw uses the pi-coding-agent model catalog. Add custom providers via `model
- Non-empty agent `models.json` `baseUrl` values win.
- Non-empty agent `apiKey` values win only when that provider is not SecretRef-managed in current config/auth-profile context.
- SecretRef-managed provider `apiKey` values are refreshed from source markers (`ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs) instead of persisting resolved secrets.
+ - SecretRef-managed provider header values are refreshed from source markers (`secretref-env:ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs).
- Empty or missing agent `apiKey`/`baseUrl` fall back to `models.providers` in config.
- Matching model `contextWindow`/`maxTokens` use the higher value between explicit config and implicit catalog values.
- Use `models.mode: "replace"` when you want config to fully rewrite `models.json`.
+ - Marker persistence is source-authoritative: markers are written from the active source config snapshot (pre-resolution), not from resolved runtime secret values.
### Provider field details
diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md
index 2a5fc5a66ac..76eb4ec2ae1 100644
--- a/docs/reference/secretref-credential-surface.md
+++ b/docs/reference/secretref-credential-surface.md
@@ -101,6 +101,7 @@ Notes:
- Plan entries target `profiles.*.key` / `profiles.*.token` and write sibling refs (`keyRef` / `tokenRef`).
- Auth-profile refs are included in runtime resolution and audit coverage.
- For SecretRef-managed model providers, generated `agents/*/agent/models.json` entries persist non-secret markers (not resolved secret values) for `apiKey`/header surfaces.
+- Marker persistence is source-authoritative: OpenClaw writes markers from the active source config snapshot (pre-resolution), not from resolved runtime secret values.
- For web search:
- In explicit provider mode (`tools.web.search.provider` set), only the selected provider key is active.
- In auto mode (`tools.web.search.provider` unset), only the first provider key that resolves by precedence is active.
diff --git a/docs/tools/llm-task.md b/docs/tools/llm-task.md
index e6f574d078e..2626d3237e4 100644
--- a/docs/tools/llm-task.md
+++ b/docs/tools/llm-task.md
@@ -75,11 +75,14 @@ outside the list is rejected.
- `schema` (object, optional JSON Schema)
- `provider` (string, optional)
- `model` (string, optional)
+- `thinking` (string, optional)
- `authProfileId` (string, optional)
- `temperature` (number, optional)
- `maxTokens` (number, optional)
- `timeoutMs` (number, optional)
+`thinking` accepts the standard OpenClaw reasoning presets, such as `low` or `medium`.
+
## Output
Returns `details.json` containing the parsed JSON (and validates against
@@ -90,6 +93,7 @@ Returns `details.json` containing the parsed JSON (and validates against
```lobster
openclaw.invoke --tool llm-task --action json --args-json '{
"prompt": "Given the input email, return intent and draft.",
+ "thinking": "low",
"input": {
"subject": "Hello",
"body": "Can you help?"
diff --git a/docs/tools/lobster.md b/docs/tools/lobster.md
index 65ff4f56dfb..5c8a47e4d62 100644
--- a/docs/tools/lobster.md
+++ b/docs/tools/lobster.md
@@ -106,6 +106,7 @@ Use it in a pipeline:
```lobster
openclaw.invoke --tool llm-task --action json --args-json '{
"prompt": "Given the input email, return intent and draft.",
+ "thinking": "low",
"input": { "subject": "Hello", "body": "Can you help?" },
"schema": {
"type": "object",
diff --git a/extensions/bluebubbles/src/monitor-normalize.test.ts b/extensions/bluebubbles/src/monitor-normalize.test.ts
index 3986909c259..3e06302593c 100644
--- a/extensions/bluebubbles/src/monitor-normalize.test.ts
+++ b/extensions/bluebubbles/src/monitor-normalize.test.ts
@@ -17,9 +17,28 @@ describe("normalizeWebhookMessage", () => {
expect(result).not.toBeNull();
expect(result?.senderId).toBe("+15551234567");
+ expect(result?.senderIdExplicit).toBe(false);
expect(result?.chatGuid).toBe("iMessage;-;+15551234567");
});
+ it("marks explicit sender handles as explicit identity", () => {
+ const result = normalizeWebhookMessage({
+ type: "new-message",
+ data: {
+ guid: "msg-explicit-1",
+ text: "hello",
+ isGroup: false,
+ isFromMe: true,
+ handle: { address: "+15551234567" },
+ chatGuid: "iMessage;-;+15551234567",
+ },
+ });
+
+ expect(result).not.toBeNull();
+ expect(result?.senderId).toBe("+15551234567");
+ expect(result?.senderIdExplicit).toBe(true);
+ });
+
it("does not infer sender from group chatGuid when sender handle is missing", () => {
const result = normalizeWebhookMessage({
type: "new-message",
@@ -72,6 +91,7 @@ describe("normalizeWebhookReaction", () => {
expect(result).not.toBeNull();
expect(result?.senderId).toBe("+15551234567");
+ expect(result?.senderIdExplicit).toBe(false);
expect(result?.messageId).toBe("p:0/msg-1");
expect(result?.action).toBe("added");
});
diff --git a/extensions/bluebubbles/src/monitor-normalize.ts b/extensions/bluebubbles/src/monitor-normalize.ts
index 173ea9c24a6..83454602d4c 100644
--- a/extensions/bluebubbles/src/monitor-normalize.ts
+++ b/extensions/bluebubbles/src/monitor-normalize.ts
@@ -191,12 +191,13 @@ function readFirstChatRecord(message: Record): Record): {
senderId: string;
+ senderIdExplicit: boolean;
senderName?: string;
} {
const handleValue = message.handle ?? message.sender;
const handle =
asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
- const senderId =
+ const senderIdRaw =
readString(handle, "address") ??
readString(handle, "handle") ??
readString(handle, "id") ??
@@ -204,13 +205,18 @@ function extractSenderInfo(message: Record): {
readString(message, "sender") ??
readString(message, "from") ??
"";
+ const senderId = senderIdRaw.trim();
const senderName =
readString(handle, "displayName") ??
readString(handle, "name") ??
readString(message, "senderName") ??
undefined;
- return { senderId, senderName };
+ return {
+ senderId,
+ senderIdExplicit: Boolean(senderId),
+ senderName,
+ };
}
function extractChatContext(message: Record): {
@@ -441,6 +447,7 @@ export type BlueBubblesParticipant = {
export type NormalizedWebhookMessage = {
text: string;
senderId: string;
+ senderIdExplicit: boolean;
senderName?: string;
messageId?: string;
timestamp?: number;
@@ -466,6 +473,7 @@ export type NormalizedWebhookReaction = {
action: "added" | "removed";
emoji: string;
senderId: string;
+ senderIdExplicit: boolean;
senderName?: string;
messageId: string;
timestamp?: number;
@@ -672,7 +680,7 @@ export function normalizeWebhookMessage(
readString(message, "subject") ??
"";
- const { senderId, senderName } = extractSenderInfo(message);
+ const { senderId, senderIdExplicit, senderName } = extractSenderInfo(message);
const { chatGuid, chatIdentifier, chatId, chatName, isGroup, participants } =
extractChatContext(message);
const normalizedParticipants = normalizeParticipantList(participants);
@@ -717,7 +725,7 @@ export function normalizeWebhookMessage(
// BlueBubbles may omit `handle` in webhook payloads; for DM chat GUIDs we can still infer sender.
const senderFallbackFromChatGuid =
- !senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
+ !senderIdExplicit && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
if (!normalizedSender) {
return null;
@@ -727,6 +735,7 @@ export function normalizeWebhookMessage(
return {
text,
senderId: normalizedSender,
+ senderIdExplicit,
senderName,
messageId,
timestamp,
@@ -777,7 +786,7 @@ export function normalizeWebhookReaction(
const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
- const { senderId, senderName } = extractSenderInfo(message);
+ const { senderId, senderIdExplicit, senderName } = extractSenderInfo(message);
const { chatGuid, chatIdentifier, chatId, chatName, isGroup } = extractChatContext(message);
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
@@ -793,7 +802,7 @@ export function normalizeWebhookReaction(
: undefined;
const senderFallbackFromChatGuid =
- !senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
+ !senderIdExplicit && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
if (!normalizedSender) {
return null;
@@ -803,6 +812,7 @@ export function normalizeWebhookReaction(
action,
emoji,
senderId: normalizedSender,
+ senderIdExplicit,
senderName,
messageId: associatedGuid,
timestamp,
diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts
index 6eb2ab08bc0..9cf72ea1efd 100644
--- a/extensions/bluebubbles/src/monitor-processing.ts
+++ b/extensions/bluebubbles/src/monitor-processing.ts
@@ -38,6 +38,10 @@ import {
resolveBlueBubblesMessageId,
resolveReplyContextFromCache,
} from "./monitor-reply-cache.js";
+import {
+ hasBlueBubblesSelfChatCopy,
+ rememberBlueBubblesSelfChatCopy,
+} from "./monitor-self-chat-cache.js";
import type {
BlueBubblesCoreRuntime,
BlueBubblesRuntimeEnv,
@@ -47,7 +51,12 @@ import { isBlueBubblesPrivateApiEnabled } from "./probe.js";
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
-import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js";
+import {
+ extractHandleFromChatGuid,
+ formatBlueBubblesChatTarget,
+ isAllowedBlueBubblesSender,
+ normalizeBlueBubblesHandle,
+} from "./targets.js";
const DEFAULT_TEXT_LIMIT = 4000;
const invalidAckReactions = new Set();
@@ -80,6 +89,19 @@ function normalizeSnippet(value: string): string {
return stripMarkdown(value).replace(/\s+/g, " ").trim().toLowerCase();
}
+function isBlueBubblesSelfChatMessage(
+ message: NormalizedWebhookMessage,
+ isGroup: boolean,
+): boolean {
+ if (isGroup || !message.senderIdExplicit) {
+ return false;
+ }
+ const chatHandle =
+ (message.chatGuid ? extractHandleFromChatGuid(message.chatGuid) : null) ??
+ normalizeBlueBubblesHandle(message.chatIdentifier ?? "");
+ return Boolean(chatHandle) && chatHandle === message.senderId;
+}
+
function prunePendingOutboundMessageIds(now = Date.now()): void {
const cutoff = now - PENDING_OUTBOUND_MESSAGE_ID_TTL_MS;
for (let i = pendingOutboundMessageIds.length - 1; i >= 0; i--) {
@@ -453,8 +475,27 @@ export async function processMessage(
? `removed ${tapbackParsed.emoji} reaction`
: `reacted with ${tapbackParsed.emoji}`
: text || placeholder;
+ const isSelfChatMessage = isBlueBubblesSelfChatMessage(message, isGroup);
+ const selfChatLookup = {
+ accountId: account.accountId,
+ chatGuid: message.chatGuid,
+ chatIdentifier: message.chatIdentifier,
+ chatId: message.chatId,
+ senderId: message.senderId,
+ body: rawBody,
+ timestamp: message.timestamp,
+ };
const cacheMessageId = message.messageId?.trim();
+ const confirmedOutboundCacheEntry = cacheMessageId
+ ? resolveReplyContextFromCache({
+ accountId: account.accountId,
+ replyToId: cacheMessageId,
+ chatGuid: message.chatGuid,
+ chatIdentifier: message.chatIdentifier,
+ chatId: message.chatId,
+ })
+ : null;
let messageShortId: string | undefined;
const cacheInboundMessage = () => {
if (!cacheMessageId) {
@@ -476,6 +517,12 @@ export async function processMessage(
if (message.fromMe) {
// Cache from-me messages so reply context can resolve sender/body.
cacheInboundMessage();
+ const confirmedAssistantOutbound =
+ confirmedOutboundCacheEntry?.senderLabel === "me" &&
+ normalizeSnippet(confirmedOutboundCacheEntry.body ?? "") === normalizeSnippet(rawBody);
+ if (isSelfChatMessage && confirmedAssistantOutbound) {
+ rememberBlueBubblesSelfChatCopy(selfChatLookup);
+ }
if (cacheMessageId) {
const pending = consumePendingOutboundMessageId({
accountId: account.accountId,
@@ -499,6 +546,11 @@ export async function processMessage(
return;
}
+ if (isSelfChatMessage && hasBlueBubblesSelfChatCopy(selfChatLookup)) {
+ logVerbose(core, runtime, `drop: reflected self-chat duplicate sender=${message.senderId}`);
+ return;
+ }
+
if (!rawBody) {
logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
return;
diff --git a/extensions/bluebubbles/src/monitor-self-chat-cache.test.ts b/extensions/bluebubbles/src/monitor-self-chat-cache.test.ts
new file mode 100644
index 00000000000..3e843f6943d
--- /dev/null
+++ b/extensions/bluebubbles/src/monitor-self-chat-cache.test.ts
@@ -0,0 +1,190 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ hasBlueBubblesSelfChatCopy,
+ rememberBlueBubblesSelfChatCopy,
+ resetBlueBubblesSelfChatCache,
+} from "./monitor-self-chat-cache.js";
+
+describe("BlueBubbles self-chat cache", () => {
+ const directLookup = {
+ accountId: "default",
+ chatGuid: "iMessage;-;+15551234567",
+ senderId: "+15551234567",
+ } as const;
+
+ afterEach(() => {
+ resetBlueBubblesSelfChatCache();
+ vi.useRealTimers();
+ });
+
+ it("matches repeated lookups for the same scope, timestamp, and text", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ rememberBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: " hello\r\nworld ",
+ timestamp: 123,
+ });
+
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: "hello\nworld",
+ timestamp: 123,
+ }),
+ ).toBe(true);
+ });
+
+ it("canonicalizes DM scope across chatIdentifier and chatGuid", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ rememberBlueBubblesSelfChatCopy({
+ accountId: "default",
+ chatIdentifier: "+15551234567",
+ senderId: "+15551234567",
+ body: "hello",
+ timestamp: 123,
+ });
+
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ accountId: "default",
+ chatGuid: "iMessage;-;+15551234567",
+ senderId: "+15551234567",
+ body: "hello",
+ timestamp: 123,
+ }),
+ ).toBe(true);
+
+ resetBlueBubblesSelfChatCache();
+
+ rememberBlueBubblesSelfChatCopy({
+ accountId: "default",
+ chatGuid: "iMessage;-;+15551234567",
+ senderId: "+15551234567",
+ body: "hello",
+ timestamp: 123,
+ });
+
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ accountId: "default",
+ chatIdentifier: "+15551234567",
+ senderId: "+15551234567",
+ body: "hello",
+ timestamp: 123,
+ }),
+ ).toBe(true);
+ });
+
+ it("expires entries after the ttl window", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ rememberBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: "hello",
+ timestamp: 123,
+ });
+
+ vi.advanceTimersByTime(11_001);
+
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: "hello",
+ timestamp: 123,
+ }),
+ ).toBe(false);
+ });
+
+ it("evicts older entries when the cache exceeds its cap", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ for (let i = 0; i < 513; i += 1) {
+ rememberBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: `message-${i}`,
+ timestamp: i,
+ });
+ vi.advanceTimersByTime(1_001);
+ }
+
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: "message-0",
+ timestamp: 0,
+ }),
+ ).toBe(false);
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: "message-512",
+ timestamp: 512,
+ }),
+ ).toBe(true);
+ });
+
+ it("enforces the cache cap even when cleanup is throttled", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ for (let i = 0; i < 513; i += 1) {
+ rememberBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: `burst-${i}`,
+ timestamp: i,
+ });
+ }
+
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: "burst-0",
+ timestamp: 0,
+ }),
+ ).toBe(false);
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: "burst-512",
+ timestamp: 512,
+ }),
+ ).toBe(true);
+ });
+
+ it("does not collide long texts that differ only in the middle", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ const prefix = "a".repeat(256);
+ const suffix = "b".repeat(256);
+ const longBodyA = `${prefix}${"x".repeat(300)}${suffix}`;
+ const longBodyB = `${prefix}${"y".repeat(300)}${suffix}`;
+
+ rememberBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: longBodyA,
+ timestamp: 123,
+ });
+
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: longBodyA,
+ timestamp: 123,
+ }),
+ ).toBe(true);
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: longBodyB,
+ timestamp: 123,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/extensions/bluebubbles/src/monitor-self-chat-cache.ts b/extensions/bluebubbles/src/monitor-self-chat-cache.ts
new file mode 100644
index 00000000000..09d7167d769
--- /dev/null
+++ b/extensions/bluebubbles/src/monitor-self-chat-cache.ts
@@ -0,0 +1,127 @@
+import { createHash } from "node:crypto";
+import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
+
+type SelfChatCacheKeyParts = {
+ accountId: string;
+ chatGuid?: string;
+ chatIdentifier?: string;
+ chatId?: number;
+ senderId: string;
+};
+
+type SelfChatLookup = SelfChatCacheKeyParts & {
+ body?: string;
+ timestamp?: number;
+};
+
+const SELF_CHAT_TTL_MS = 10_000;
+const MAX_SELF_CHAT_CACHE_ENTRIES = 512;
+const CLEANUP_MIN_INTERVAL_MS = 1_000;
+const MAX_SELF_CHAT_BODY_CHARS = 32_768;
+const cache = new Map();
+let lastCleanupAt = 0;
+
+function normalizeBody(body: string | undefined): string | null {
+ if (!body) {
+ return null;
+ }
+ const bounded =
+ body.length > MAX_SELF_CHAT_BODY_CHARS ? body.slice(0, MAX_SELF_CHAT_BODY_CHARS) : body;
+ const normalized = bounded.replace(/\r\n?/g, "\n").trim();
+ return normalized ? normalized : null;
+}
+
+function isUsableTimestamp(timestamp: number | undefined): timestamp is number {
+ return typeof timestamp === "number" && Number.isFinite(timestamp);
+}
+
+function digestText(text: string): string {
+ return createHash("sha256").update(text).digest("base64url");
+}
+
+function trimOrUndefined(value?: string | null): string | undefined {
+ const trimmed = value?.trim();
+ return trimmed ? trimmed : undefined;
+}
+
+function resolveCanonicalChatTarget(parts: SelfChatCacheKeyParts): string | null {
+ const handleFromGuid = parts.chatGuid ? extractHandleFromChatGuid(parts.chatGuid) : null;
+ if (handleFromGuid) {
+ return handleFromGuid;
+ }
+
+ const normalizedIdentifier = normalizeBlueBubblesHandle(parts.chatIdentifier ?? "");
+ if (normalizedIdentifier) {
+ return normalizedIdentifier;
+ }
+
+ return (
+ trimOrUndefined(parts.chatGuid) ??
+ trimOrUndefined(parts.chatIdentifier) ??
+ (typeof parts.chatId === "number" ? String(parts.chatId) : null)
+ );
+}
+
+function buildScope(parts: SelfChatCacheKeyParts): string {
+ const target = resolveCanonicalChatTarget(parts) ?? parts.senderId;
+ return `${parts.accountId}:${target}`;
+}
+
+function cleanupExpired(now = Date.now()): void {
+ if (
+ lastCleanupAt !== 0 &&
+ now >= lastCleanupAt &&
+ now - lastCleanupAt < CLEANUP_MIN_INTERVAL_MS
+ ) {
+ return;
+ }
+ lastCleanupAt = now;
+ for (const [key, seenAt] of cache.entries()) {
+ if (now - seenAt > SELF_CHAT_TTL_MS) {
+ cache.delete(key);
+ }
+ }
+}
+
+function enforceSizeCap(): void {
+ while (cache.size > MAX_SELF_CHAT_CACHE_ENTRIES) {
+ const oldestKey = cache.keys().next().value;
+ if (typeof oldestKey !== "string") {
+ break;
+ }
+ cache.delete(oldestKey);
+ }
+}
+
+function buildKey(lookup: SelfChatLookup): string | null {
+ const body = normalizeBody(lookup.body);
+ if (!body || !isUsableTimestamp(lookup.timestamp)) {
+ return null;
+ }
+ return `${buildScope(lookup)}:${lookup.timestamp}:${digestText(body)}`;
+}
+
+export function rememberBlueBubblesSelfChatCopy(lookup: SelfChatLookup): void {
+ cleanupExpired();
+ const key = buildKey(lookup);
+ if (!key) {
+ return;
+ }
+ cache.set(key, Date.now());
+ enforceSizeCap();
+}
+
+export function hasBlueBubblesSelfChatCopy(lookup: SelfChatLookup): boolean {
+ cleanupExpired();
+ const key = buildKey(lookup);
+ if (!key) {
+ return false;
+ }
+ const seenAt = cache.get(key);
+ return typeof seenAt === "number" && Date.now() - seenAt <= SELF_CHAT_TTL_MS;
+}
+
+export function resetBlueBubblesSelfChatCache(): void {
+ cache.clear();
+ lastCleanupAt = 0;
+}
diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts
index b02019058b8..1ba2e27f0b6 100644
--- a/extensions/bluebubbles/src/monitor.test.ts
+++ b/extensions/bluebubbles/src/monitor.test.ts
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { fetchBlueBubblesHistory } from "./history.js";
+import { resetBlueBubblesSelfChatCache } from "./monitor-self-chat-cache.js";
import {
handleBlueBubblesWebhookRequest,
registerBlueBubblesWebhookTarget,
@@ -246,6 +247,7 @@ describe("BlueBubbles webhook monitor", () => {
vi.clearAllMocks();
// Reset short ID state between tests for predictable behavior
_resetBlueBubblesShortIdState();
+ resetBlueBubblesSelfChatCache();
mockFetchBlueBubblesHistory.mockResolvedValue({ entries: [], resolved: true });
mockReadAllowFromStore.mockResolvedValue([]);
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
@@ -259,6 +261,7 @@ describe("BlueBubbles webhook monitor", () => {
afterEach(() => {
unregister?.();
+ vi.useRealTimers();
});
describe("DM pairing behavior vs allowFrom", () => {
@@ -2676,5 +2679,449 @@ describe("BlueBubbles webhook monitor", () => {
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
+
+ it("drops reflected self-chat duplicates after a confirmed assistant outbound", async () => {
+ const account = createMockAccount({ dmPolicy: "open" });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ const { sendMessageBlueBubbles } = await import("./send.js");
+ vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "msg-self-1" });
+
+ mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
+ await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
+ return EMPTY_DISPATCH_RESULT;
+ });
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const timestamp = Date.now();
+ const inboundPayload = {
+ type: "new-message",
+ data: {
+ text: "hello",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-self-0",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
+
+ const fromMePayload = {
+ type: "new-message",
+ data: {
+ text: "replying now",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: true,
+ guid: "msg-self-1",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ const reflectedPayload = {
+ type: "new-message",
+ data: {
+ text: "replying now",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-self-2",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
+ });
+
+ it("does not drop inbound messages when no fromMe self-chat copy was seen", async () => {
+ const account = createMockAccount({ dmPolicy: "open" });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const inboundPayload = {
+ type: "new-message",
+ data: {
+ text: "genuinely new message",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-inbound-1",
+ chatGuid: "iMessage;-;+15551234567",
+ date: Date.now(),
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
+ });
+
+ it("does not drop reflected copies after the self-chat cache TTL expires", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ const account = createMockAccount({ dmPolicy: "open" });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const timestamp = Date.now();
+ const fromMePayload = {
+ type: "new-message",
+ data: {
+ text: "ttl me",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: true,
+ guid: "msg-self-ttl-1",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
+ createMockResponse(),
+ );
+ await vi.runAllTimersAsync();
+
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
+ vi.advanceTimersByTime(10_001);
+
+ const reflectedPayload = {
+ type: "new-message",
+ data: {
+ text: "ttl me",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-self-ttl-2",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
+ createMockResponse(),
+ );
+ await vi.runAllTimersAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
+ });
+
+ it("does not cache regular fromMe DMs as self-chat reflections", async () => {
+ const account = createMockAccount({ dmPolicy: "open" });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const timestamp = Date.now();
+ const fromMePayload = {
+ type: "new-message",
+ data: {
+ text: "shared text",
+ handle: { address: "+15557654321" },
+ isGroup: false,
+ isFromMe: true,
+ guid: "msg-normal-fromme",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
+
+ const inboundPayload = {
+ type: "new-message",
+ data: {
+ text: "shared text",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-normal-inbound",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
+ });
+
+ it("does not drop user-authored self-chat prompts without a confirmed assistant outbound", async () => {
+ const account = createMockAccount({ dmPolicy: "open" });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const timestamp = Date.now();
+ const fromMePayload = {
+ type: "new-message",
+ data: {
+ text: "user-authored self prompt",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: true,
+ guid: "msg-self-user-1",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
+
+ const reflectedPayload = {
+ type: "new-message",
+ data: {
+ text: "user-authored self prompt",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-self-user-2",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
+ });
+
+ it("does not treat a pending text-only match as confirmed assistant outbound", async () => {
+ const account = createMockAccount({ dmPolicy: "open" });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ const { sendMessageBlueBubbles } = await import("./send.js");
+ vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
+
+ mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
+ await params.dispatcherOptions.deliver({ text: "same text" }, { kind: "final" });
+ return EMPTY_DISPATCH_RESULT;
+ });
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const timestamp = Date.now();
+ const inboundPayload = {
+ type: "new-message",
+ data: {
+ text: "hello",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-self-race-0",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
+
+ const fromMePayload = {
+ type: "new-message",
+ data: {
+ text: "same text",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: true,
+ guid: "msg-self-race-1",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ const reflectedPayload = {
+ type: "new-message",
+ data: {
+ text: "same text",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-self-race-2",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
+ });
+
+ it("does not treat chatGuid-inferred sender ids as self-chat evidence", async () => {
+ const account = createMockAccount({ dmPolicy: "open" });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const timestamp = Date.now();
+ const fromMePayload = {
+ type: "new-message",
+ data: {
+ text: "shared inferred text",
+ handle: null,
+ isGroup: false,
+ isFromMe: true,
+ guid: "msg-inferred-fromme",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
+
+ const inboundPayload = {
+ type: "new-message",
+ data: {
+ text: "shared inferred text",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-inferred-inbound",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
+ });
});
});
diff --git a/extensions/llm-task/README.md b/extensions/llm-task/README.md
index d8e5dadc6fb..738208f3d60 100644
--- a/extensions/llm-task/README.md
+++ b/extensions/llm-task/README.md
@@ -69,6 +69,7 @@ outside the list is rejected.
- `schema` (object, optional JSON Schema)
- `provider` (string, optional)
- `model` (string, optional)
+- `thinking` (string, optional)
- `authProfileId` (string, optional)
- `temperature` (number, optional)
- `maxTokens` (number, optional)
diff --git a/extensions/llm-task/src/llm-task-tool.test.ts b/extensions/llm-task/src/llm-task-tool.test.ts
index fea135e8be5..fc9f0e07215 100644
--- a/extensions/llm-task/src/llm-task-tool.test.ts
+++ b/extensions/llm-task/src/llm-task-tool.test.ts
@@ -109,6 +109,59 @@ describe("llm-task tool (json-only)", () => {
expect(call.model).toBe("claude-4-sonnet");
});
+ it("passes thinking override to embedded runner", async () => {
+ // oxlint-disable-next-line typescript/no-explicit-any
+ (runEmbeddedPiAgent as any).mockResolvedValueOnce({
+ meta: {},
+ payloads: [{ text: JSON.stringify({ ok: true }) }],
+ });
+ const tool = createLlmTaskTool(fakeApi());
+ await tool.execute("id", { prompt: "x", thinking: "high" });
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
+ expect(call.thinkLevel).toBe("high");
+ });
+
+ it("normalizes thinking aliases", async () => {
+ // oxlint-disable-next-line typescript/no-explicit-any
+ (runEmbeddedPiAgent as any).mockResolvedValueOnce({
+ meta: {},
+ payloads: [{ text: JSON.stringify({ ok: true }) }],
+ });
+ const tool = createLlmTaskTool(fakeApi());
+ await tool.execute("id", { prompt: "x", thinking: "on" });
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
+ expect(call.thinkLevel).toBe("low");
+ });
+
+ it("throws on invalid thinking level", async () => {
+ const tool = createLlmTaskTool(fakeApi());
+ await expect(tool.execute("id", { prompt: "x", thinking: "banana" })).rejects.toThrow(
+ /invalid thinking level/i,
+ );
+ });
+
+ it("throws on unsupported xhigh thinking level", async () => {
+ const tool = createLlmTaskTool(fakeApi());
+ await expect(tool.execute("id", { prompt: "x", thinking: "xhigh" })).rejects.toThrow(
+ /only supported/i,
+ );
+ });
+
+ it("does not pass thinkLevel when thinking is omitted", async () => {
+ // oxlint-disable-next-line typescript/no-explicit-any
+ (runEmbeddedPiAgent as any).mockResolvedValueOnce({
+ meta: {},
+ payloads: [{ text: JSON.stringify({ ok: true }) }],
+ });
+ const tool = createLlmTaskTool(fakeApi());
+ await tool.execute("id", { prompt: "x" });
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
+ expect(call.thinkLevel).toBeUndefined();
+ });
+
it("enforces allowedModels", async () => {
// oxlint-disable-next-line typescript/no-explicit-any
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts
index 3a2e42c7223..ff2037e534a 100644
--- a/extensions/llm-task/src/llm-task-tool.ts
+++ b/extensions/llm-task/src/llm-task-tool.ts
@@ -2,7 +2,13 @@ import fs from "node:fs/promises";
import path from "node:path";
import { Type } from "@sinclair/typebox";
import Ajv from "ajv";
-import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/llm-task";
+import {
+ formatThinkingLevels,
+ formatXHighModelHint,
+ normalizeThinkLevel,
+ resolvePreferredOpenClawTmpDir,
+ supportsXHighThinking,
+} from "openclaw/plugin-sdk/llm-task";
// NOTE: This extension is intended to be bundled with OpenClaw.
// When running from source (tests/dev), OpenClaw internals live under src/.
// When running from a built install, internals live under dist/ (no src/ tree).
@@ -86,6 +92,7 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
Type.String({ description: "Provider override (e.g. openai-codex, anthropic)." }),
),
model: Type.Optional(Type.String({ description: "Model id override." })),
+ thinking: Type.Optional(Type.String({ description: "Thinking level override." })),
authProfileId: Type.Optional(Type.String({ description: "Auth profile override." })),
temperature: Type.Optional(Type.Number({ description: "Best-effort temperature override." })),
maxTokens: Type.Optional(Type.Number({ description: "Best-effort maxTokens override." })),
@@ -144,6 +151,18 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
);
}
+ const thinkingRaw =
+ typeof params.thinking === "string" && params.thinking.trim() ? params.thinking : undefined;
+ const thinkLevel = thinkingRaw ? normalizeThinkLevel(thinkingRaw) : undefined;
+ if (thinkingRaw && !thinkLevel) {
+ throw new Error(
+ `Invalid thinking level "${thinkingRaw}". Use one of: ${formatThinkingLevels(provider, model)}.`,
+ );
+ }
+ if (thinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) {
+ throw new Error(`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`);
+ }
+
const timeoutMs =
(typeof params.timeoutMs === "number" && params.timeoutMs > 0
? params.timeoutMs
@@ -204,6 +223,7 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
model,
authProfileId,
authProfileIdSource: authProfileId ? "user" : "auto",
+ thinkLevel,
streamParams,
disableTools: true,
});
diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts
index 2dffaa6f3cf..42d167948a0 100644
--- a/extensions/mattermost/src/channel.ts
+++ b/extensions/mattermost/src/channel.ts
@@ -270,6 +270,16 @@ export const mattermostPlugin: ChannelPlugin = {
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
+ threading: {
+ resolveReplyToMode: ({ cfg, accountId }) => {
+ const account = resolveMattermostAccount({ cfg, accountId: accountId ?? "default" });
+ const mode = account.config.replyToMode;
+ if (mode === "off" || mode === "first") {
+ return mode;
+ }
+ return "all";
+ },
+ },
reload: { configPrefixes: ["channels.mattermost"] },
configSchema: buildChannelConfigSchema(MattermostConfigSchema),
config: {
diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts
index 51d9bdbe33a..43dd7ede8d2 100644
--- a/extensions/mattermost/src/config-schema.ts
+++ b/extensions/mattermost/src/config-schema.ts
@@ -43,6 +43,7 @@ const MattermostAccountSchemaBase = z
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
+ replyToMode: z.enum(["off", "first", "all"]).optional(),
responsePrefix: z.string().optional(),
actions: z
.object({
diff --git a/extensions/mattermost/src/mattermost/monitor.test.ts b/extensions/mattermost/src/mattermost/monitor.test.ts
index 1bd871714c4..d479909ac05 100644
--- a/extensions/mattermost/src/mattermost/monitor.test.ts
+++ b/extensions/mattermost/src/mattermost/monitor.test.ts
@@ -109,6 +109,29 @@ describe("mattermost mention gating", () => {
});
});
+describe("resolveMattermostReplyRootId with block streaming payloads", () => {
+ it("uses threadRootId for block-streamed payloads with replyToId", () => {
+ // When block streaming sends a payload with replyToId from the threading
+ // mode, the deliver callback should still use the existing threadRootId.
+ expect(
+ resolveMattermostReplyRootId({
+ threadRootId: "thread-root-1",
+ replyToId: "streamed-reply-id",
+ }),
+ ).toBe("thread-root-1");
+ });
+
+ it("falls back to payload replyToId when no threadRootId in block streaming", () => {
+ // Top-level channel message: no threadRootId, payload carries the
+ // inbound post id as replyToId from the "all" threading mode.
+ expect(
+ resolveMattermostReplyRootId({
+ replyToId: "inbound-post-for-threading",
+ }),
+ ).toBe("inbound-post-for-threading");
+ });
+});
+
describe("resolveMattermostReplyRootId", () => {
it("uses replyToId for top-level replies", () => {
expect(
diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts
index ba664baa894..86de9c1a714 100644
--- a/extensions/mattermost/src/types.ts
+++ b/extensions/mattermost/src/types.ts
@@ -52,6 +52,8 @@ export type MattermostAccountConfig = {
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
+ /** Control reply threading (off|first|all). Default: "all". */
+ replyToMode?: "off" | "first" | "all";
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
/** Action toggles for this account. */
diff --git a/extensions/zalouser/src/channel.sendpayload.test.ts b/extensions/zalouser/src/channel.sendpayload.test.ts
index 0cef65f8c05..d388773e2e6 100644
--- a/extensions/zalouser/src/channel.sendpayload.test.ts
+++ b/extensions/zalouser/src/channel.sendpayload.test.ts
@@ -5,6 +5,7 @@ import {
primeSendMock,
} from "../../../src/test-utils/send-payload-contract.js";
import { zalouserPlugin } from "./channel.js";
+import { setZalouserRuntime } from "./runtime.js";
vi.mock("./send.js", () => ({
sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" }),
@@ -38,6 +39,14 @@ describe("zalouserPlugin outbound sendPayload", () => {
let mockedSend: ReturnType>;
beforeEach(async () => {
+ setZalouserRuntime({
+ channel: {
+ text: {
+ resolveChunkMode: vi.fn(() => "length"),
+ resolveTextChunkLimit: vi.fn(() => 1200),
+ },
+ },
+ } as never);
const mod = await import("./send.js");
mockedSend = vi.mocked(mod.sendMessageZalouser);
mockedSend.mockClear();
@@ -55,7 +64,7 @@ describe("zalouserPlugin outbound sendPayload", () => {
expect(mockedSend).toHaveBeenCalledWith(
"1471383327500481391",
"hello group",
- expect.objectContaining({ isGroup: true }),
+ expect.objectContaining({ isGroup: true, textMode: "markdown" }),
);
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g1" });
});
@@ -71,7 +80,7 @@ describe("zalouserPlugin outbound sendPayload", () => {
expect(mockedSend).toHaveBeenCalledWith(
"987654321",
"hello",
- expect.objectContaining({ isGroup: false }),
+ expect.objectContaining({ isGroup: false, textMode: "markdown" }),
);
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-d1" });
});
@@ -87,14 +96,37 @@ describe("zalouserPlugin outbound sendPayload", () => {
expect(mockedSend).toHaveBeenCalledWith(
"g-1471383327500481391",
"hello native group",
- expect.objectContaining({ isGroup: true }),
+ expect.objectContaining({ isGroup: true, textMode: "markdown" }),
);
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g-native" });
});
+ it("passes long markdown through once so formatting happens before chunking", async () => {
+ const text = `**${"a".repeat(2501)}**`;
+ mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-code" });
+
+ const result = await zalouserPlugin.outbound!.sendPayload!({
+ ...baseCtx({ text }),
+ to: "987654321",
+ });
+
+ expect(mockedSend).toHaveBeenCalledTimes(1);
+ expect(mockedSend).toHaveBeenCalledWith(
+ "987654321",
+ text,
+ expect.objectContaining({
+ isGroup: false,
+ textMode: "markdown",
+ textChunkMode: "length",
+ textChunkLimit: 1200,
+ }),
+ );
+ expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" });
+ });
+
installSendPayloadContractSuite({
channel: "zalouser",
- chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
+ chunking: { mode: "passthrough", longTextLength: 3000 },
createHarness: ({ payload, sendResults }) => {
primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults);
return {
diff --git a/extensions/zalouser/src/channel.test.ts b/extensions/zalouser/src/channel.test.ts
index 231bcc8b2d3..5580ddfb2e1 100644
--- a/extensions/zalouser/src/channel.test.ts
+++ b/extensions/zalouser/src/channel.test.ts
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { chunkMarkdownText } from "../../../src/auto-reply/chunk.js";
import { zalouserPlugin } from "./channel.js";
+import { setZalouserRuntime } from "./runtime.js";
import { sendReactionZalouser } from "./send.js";
vi.mock("./send.js", async (importOriginal) => {
@@ -13,6 +15,16 @@ vi.mock("./send.js", async (importOriginal) => {
const mockSendReaction = vi.mocked(sendReactionZalouser);
describe("zalouser outbound chunker", () => {
+ beforeEach(() => {
+ setZalouserRuntime({
+ channel: {
+ text: {
+ chunkMarkdownText,
+ },
+ },
+ } as never);
+ });
+
it("chunks without empty strings and respects limit", () => {
const chunker = zalouserPlugin.outbound?.chunker;
expect(chunker).toBeTypeOf("function");
diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts
index 2091124be6e..a2e3435f75c 100644
--- a/extensions/zalouser/src/channel.ts
+++ b/extensions/zalouser/src/channel.ts
@@ -20,7 +20,6 @@ import {
buildBaseAccountStatusSnapshot,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
- chunkTextForOutbound,
deleteAccountFromConfigSection,
formatAllowFromLowercase,
isNumericTargetId,
@@ -43,6 +42,7 @@ import { resolveZalouserReactionMessageIds } from "./message-sid.js";
import { zalouserOnboardingAdapter } from "./onboarding.js";
import { probeZalouser } from "./probe.js";
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
+import { getZalouserRuntime } from "./runtime.js";
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
import { collectZalouserStatusIssues } from "./status-issues.js";
import {
@@ -166,6 +166,16 @@ function resolveZalouserQrProfile(accountId?: string | null): string {
return normalized;
}
+function resolveZalouserOutboundChunkMode(cfg: OpenClawConfig, accountId?: string) {
+ return getZalouserRuntime().channel.text.resolveChunkMode(cfg, "zalouser", accountId);
+}
+
+function resolveZalouserOutboundTextChunkLimit(cfg: OpenClawConfig, accountId?: string) {
+ return getZalouserRuntime().channel.text.resolveTextChunkLimit(cfg, "zalouser", accountId, {
+ fallbackLimit: zalouserDock.outbound?.textChunkLimit ?? 2000,
+ });
+}
+
function mapUser(params: {
id: string;
name?: string | null;
@@ -595,14 +605,9 @@ export const zalouserPlugin: ChannelPlugin = {
},
outbound: {
deliveryMode: "direct",
- chunker: chunkTextForOutbound,
- chunkerMode: "text",
- textChunkLimit: 2000,
sendPayload: async (ctx) =>
await sendPayloadWithChunkedTextAndMedia({
ctx,
- textChunkLimit: zalouserPlugin.outbound!.textChunkLimit,
- chunker: zalouserPlugin.outbound!.chunker,
sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx),
sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx),
emptyResult: { channel: "zalouser", messageId: "" },
@@ -613,6 +618,9 @@ export const zalouserPlugin: ChannelPlugin = {
const result = await sendMessageZalouser(target.threadId, text, {
profile: account.profile,
isGroup: target.isGroup,
+ textMode: "markdown",
+ textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
+ textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
});
return buildChannelSendResult("zalouser", result);
},
@@ -624,6 +632,9 @@ export const zalouserPlugin: ChannelPlugin = {
isGroup: target.isGroup,
mediaUrl,
mediaLocalRoots,
+ textMode: "markdown",
+ textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
+ textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
});
return buildChannelSendResult("zalouser", result);
},
diff --git a/extensions/zalouser/src/monitor.group-gating.test.ts b/extensions/zalouser/src/monitor.group-gating.test.ts
index b3e38efecd6..49593f07072 100644
--- a/extensions/zalouser/src/monitor.group-gating.test.ts
+++ b/extensions/zalouser/src/monitor.group-gating.test.ts
@@ -51,6 +51,7 @@ function createRuntimeEnv(): RuntimeEnv {
function installRuntime(params: {
commandAuthorized?: boolean;
+ replyPayload?: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
resolveCommandAuthorizedFromAuthorizers?: (params: {
useAccessGroups: boolean;
authorizers: Array<{ configured: boolean; allowed: boolean }>;
@@ -58,6 +59,9 @@ function installRuntime(params: {
}) {
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions, ctx }) => {
await dispatcherOptions.typingCallbacks?.onReplyStart?.();
+ if (params.replyPayload) {
+ await dispatcherOptions.deliver(params.replyPayload);
+ }
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, ctx };
});
const resolveCommandAuthorizedFromAuthorizers = vi.fn(
@@ -166,7 +170,8 @@ function installRuntime(params: {
text: {
resolveMarkdownTableMode: vi.fn(() => "code"),
convertMarkdownTables: vi.fn((text: string) => text),
- resolveChunkMode: vi.fn(() => "line"),
+ resolveChunkMode: vi.fn(() => "length"),
+ resolveTextChunkLimit: vi.fn(() => 1200),
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
},
},
@@ -304,6 +309,42 @@ describe("zalouser monitor group mention gating", () => {
expect(callArg?.ctx?.WasMentioned).toBe(true);
});
+ it("passes long markdown replies through once so formatting happens before chunking", async () => {
+ const replyText = `**${"a".repeat(2501)}**`;
+ installRuntime({
+ commandAuthorized: false,
+ replyPayload: { text: replyText },
+ });
+
+ await __testing.processMessage({
+ message: createDmMessage({
+ content: "hello",
+ }),
+ account: {
+ ...createAccount(),
+ config: {
+ ...createAccount().config,
+ dmPolicy: "open",
+ },
+ },
+ config: createConfig(),
+ runtime: createRuntimeEnv(),
+ });
+
+ expect(sendMessageZalouserMock).toHaveBeenCalledTimes(1);
+ expect(sendMessageZalouserMock).toHaveBeenCalledWith(
+ "u-1",
+ replyText,
+ expect.objectContaining({
+ isGroup: false,
+ profile: "default",
+ textMode: "markdown",
+ textChunkMode: "length",
+ textChunkLimit: 1200,
+ }),
+ );
+ });
+
it("uses commandContent for mention-prefixed control commands", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: true,
diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts
index 6590082e830..5329b22fa68 100644
--- a/extensions/zalouser/src/monitor.ts
+++ b/extensions/zalouser/src/monitor.ts
@@ -703,6 +703,10 @@ async function deliverZalouserReply(params: {
params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
+ const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
+ const textChunkLimit = core.channel.text.resolveTextChunkLimit(config, "zalouser", accountId, {
+ fallbackLimit: ZALOUSER_TEXT_LIMIT,
+ });
const sentMedia = await sendMediaWithLeadingCaption({
mediaUrls: resolveOutboundMediaUrls(payload),
@@ -713,6 +717,9 @@ async function deliverZalouserReply(params: {
profile,
mediaUrl,
isGroup,
+ textMode: "markdown",
+ textChunkMode: chunkMode,
+ textChunkLimit,
});
statusSink?.({ lastOutboundAt: Date.now() });
},
@@ -725,20 +732,17 @@ async function deliverZalouserReply(params: {
}
if (text) {
- const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
- const chunks = core.channel.text.chunkMarkdownTextWithMode(
- text,
- ZALOUSER_TEXT_LIMIT,
- chunkMode,
- );
- logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
- for (const chunk of chunks) {
- try {
- await sendMessageZalouser(chatId, chunk, { profile, isGroup });
- statusSink?.({ lastOutboundAt: Date.now() });
- } catch (err) {
- runtime.error(`Zalouser message send failed: ${String(err)}`);
- }
+ try {
+ await sendMessageZalouser(chatId, text, {
+ profile,
+ isGroup,
+ textMode: "markdown",
+ textChunkMode: chunkMode,
+ textChunkLimit,
+ });
+ statusSink?.({ lastOutboundAt: Date.now() });
+ } catch (err) {
+ runtime.error(`Zalouser message send failed: ${String(err)}`);
}
}
}
diff --git a/extensions/zalouser/src/send.test.ts b/extensions/zalouser/src/send.test.ts
index 92b3cec25f2..cc920e6be7e 100644
--- a/extensions/zalouser/src/send.test.ts
+++ b/extensions/zalouser/src/send.test.ts
@@ -8,6 +8,7 @@ import {
sendSeenZalouser,
sendTypingZalouser,
} from "./send.js";
+import { parseZalouserTextStyles } from "./text-styles.js";
import {
sendZaloDeliveredEvent,
sendZaloLink,
@@ -16,6 +17,7 @@ import {
sendZaloTextMessage,
sendZaloTypingEvent,
} from "./zalo-js.js";
+import { TextStyle } from "./zca-client.js";
vi.mock("./zalo-js.js", () => ({
sendZaloTextMessage: vi.fn(),
@@ -43,36 +45,272 @@ describe("zalouser send helpers", () => {
mockSendSeen.mockReset();
});
- it("delegates text send to JS transport", async () => {
+ it("keeps plain text literal by default", async () => {
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1" });
- const result = await sendMessageZalouser("thread-1", "hello", {
+ const result = await sendMessageZalouser("thread-1", "**hello**", {
profile: "default",
isGroup: true,
});
- expect(mockSendText).toHaveBeenCalledWith("thread-1", "hello", {
- profile: "default",
- isGroup: true,
- });
+ expect(mockSendText).toHaveBeenCalledWith(
+ "thread-1",
+ "**hello**",
+ expect.objectContaining({
+ profile: "default",
+ isGroup: true,
+ }),
+ );
expect(result).toEqual({ ok: true, messageId: "mid-1" });
});
- it("maps image helper to media send", async () => {
+ it("formats markdown text when markdown mode is enabled", async () => {
+ mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1b" });
+
+ await sendMessageZalouser("thread-1", "**hello**", {
+ profile: "default",
+ isGroup: true,
+ textMode: "markdown",
+ });
+
+ expect(mockSendText).toHaveBeenCalledWith(
+ "thread-1",
+ "hello",
+ expect.objectContaining({
+ profile: "default",
+ isGroup: true,
+ textMode: "markdown",
+ textStyles: [{ start: 0, len: 5, st: TextStyle.Bold }],
+ }),
+ );
+ });
+
+ it("formats image captions in markdown mode", async () => {
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-2" });
await sendImageZalouser("thread-2", "https://example.com/a.png", {
profile: "p2",
- caption: "cap",
+ caption: "_cap_",
isGroup: false,
+ textMode: "markdown",
});
- expect(mockSendText).toHaveBeenCalledWith("thread-2", "cap", {
+ expect(mockSendText).toHaveBeenCalledWith(
+ "thread-2",
+ "cap",
+ expect.objectContaining({
+ profile: "p2",
+ caption: undefined,
+ isGroup: false,
+ mediaUrl: "https://example.com/a.png",
+ textMode: "markdown",
+ textStyles: [{ start: 0, len: 3, st: TextStyle.Italic }],
+ }),
+ );
+ });
+
+ it("does not keep the raw markdown caption as a media fallback after formatting", async () => {
+ mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-2b" });
+
+ await sendImageZalouser("thread-2", "https://example.com/a.png", {
profile: "p2",
- caption: "cap",
+ caption: "```\n```",
isGroup: false,
- mediaUrl: "https://example.com/a.png",
+ textMode: "markdown",
});
+
+ expect(mockSendText).toHaveBeenCalledWith(
+ "thread-2",
+ "",
+ expect.objectContaining({
+ profile: "p2",
+ caption: undefined,
+ isGroup: false,
+ mediaUrl: "https://example.com/a.png",
+ textMode: "markdown",
+ textStyles: undefined,
+ }),
+ );
+ });
+
+ it("rechunks normalized markdown text before sending to avoid transport truncation", async () => {
+ const text = "\t".repeat(500) + "a".repeat(1500);
+ const formatted = parseZalouserTextStyles(text);
+ mockSendText
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2c-1" })
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2c-2" });
+
+ const result = await sendMessageZalouser("thread-2c", text, {
+ profile: "p2c",
+ isGroup: false,
+ textMode: "markdown",
+ });
+
+ expect(formatted.text.length).toBeGreaterThan(2000);
+ expect(mockSendText).toHaveBeenCalledTimes(2);
+ expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
+ expect(mockSendText.mock.calls.every((call) => (call[1] as string).length <= 2000)).toBe(true);
+ expect(result).toEqual({ ok: true, messageId: "mid-2c-2" });
+ });
+
+ it("preserves text styles when splitting long formatted markdown", async () => {
+ const text = `**${"a".repeat(2501)}**`;
+ mockSendText
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-1" })
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-2" });
+
+ const result = await sendMessageZalouser("thread-2d", text, {
+ profile: "p2d",
+ isGroup: false,
+ textMode: "markdown",
+ });
+
+ expect(mockSendText).toHaveBeenNthCalledWith(
+ 1,
+ "thread-2d",
+ "a".repeat(2000),
+ expect.objectContaining({
+ profile: "p2d",
+ isGroup: false,
+ textMode: "markdown",
+ textStyles: [{ start: 0, len: 2000, st: TextStyle.Bold }],
+ }),
+ );
+ expect(mockSendText).toHaveBeenNthCalledWith(
+ 2,
+ "thread-2d",
+ "a".repeat(501),
+ expect.objectContaining({
+ profile: "p2d",
+ isGroup: false,
+ textMode: "markdown",
+ textStyles: [{ start: 0, len: 501, st: TextStyle.Bold }],
+ }),
+ );
+ expect(result).toEqual({ ok: true, messageId: "mid-2d-2" });
+ });
+
+ it("preserves formatted text and styles when newline chunk mode splits after parsing", async () => {
+ const text = `**${"a".repeat(1995)}**\n\nsecond paragraph`;
+ const formatted = parseZalouserTextStyles(text);
+ mockSendText
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-3" })
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-4" });
+
+ const result = await sendMessageZalouser("thread-2d-2", text, {
+ profile: "p2d-2",
+ isGroup: false,
+ textMode: "markdown",
+ textChunkMode: "newline",
+ });
+
+ expect(mockSendText).toHaveBeenCalledTimes(2);
+ expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
+ expect(mockSendText).toHaveBeenNthCalledWith(
+ 1,
+ "thread-2d-2",
+ `${"a".repeat(1995)}\n\n`,
+ expect.objectContaining({
+ profile: "p2d-2",
+ isGroup: false,
+ textMode: "markdown",
+ textChunkMode: "newline",
+ textStyles: [{ start: 0, len: 1995, st: TextStyle.Bold }],
+ }),
+ );
+ expect(mockSendText).toHaveBeenNthCalledWith(
+ 2,
+ "thread-2d-2",
+ "second paragraph",
+ expect.objectContaining({
+ profile: "p2d-2",
+ isGroup: false,
+ textMode: "markdown",
+ textChunkMode: "newline",
+ textStyles: undefined,
+ }),
+ );
+ expect(result).toEqual({ ok: true, messageId: "mid-2d-4" });
+ });
+
+ it("respects an explicit text chunk limit when splitting formatted markdown", async () => {
+ const text = `**${"a".repeat(1501)}**`;
+ mockSendText
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-5" })
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-6" });
+
+ const result = await sendMessageZalouser("thread-2d-3", text, {
+ profile: "p2d-3",
+ isGroup: false,
+ textMode: "markdown",
+ textChunkLimit: 1200,
+ } as never);
+
+ expect(mockSendText).toHaveBeenCalledTimes(2);
+ expect(mockSendText).toHaveBeenNthCalledWith(
+ 1,
+ "thread-2d-3",
+ "a".repeat(1200),
+ expect.objectContaining({
+ profile: "p2d-3",
+ isGroup: false,
+ textMode: "markdown",
+ textChunkLimit: 1200,
+ textStyles: [{ start: 0, len: 1200, st: TextStyle.Bold }],
+ }),
+ );
+ expect(mockSendText).toHaveBeenNthCalledWith(
+ 2,
+ "thread-2d-3",
+ "a".repeat(301),
+ expect.objectContaining({
+ profile: "p2d-3",
+ isGroup: false,
+ textMode: "markdown",
+ textChunkLimit: 1200,
+ textStyles: [{ start: 0, len: 301, st: TextStyle.Bold }],
+ }),
+ );
+ expect(result).toEqual({ ok: true, messageId: "mid-2d-6" });
+ });
+
+ it("sends overflow markdown captions as follow-up text after the media message", async () => {
+ const caption = "\t".repeat(500) + "a".repeat(1500);
+ const formatted = parseZalouserTextStyles(caption);
+ mockSendText
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2e-1" })
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2e-2" });
+
+ const result = await sendImageZalouser("thread-2e", "https://example.com/long.png", {
+ profile: "p2e",
+ caption,
+ isGroup: false,
+ textMode: "markdown",
+ });
+
+ expect(mockSendText).toHaveBeenCalledTimes(2);
+ expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
+ expect(mockSendText).toHaveBeenNthCalledWith(
+ 1,
+ "thread-2e",
+ expect.any(String),
+ expect.objectContaining({
+ profile: "p2e",
+ caption: undefined,
+ isGroup: false,
+ mediaUrl: "https://example.com/long.png",
+ textMode: "markdown",
+ }),
+ );
+ expect(mockSendText).toHaveBeenNthCalledWith(
+ 2,
+ "thread-2e",
+ expect.any(String),
+ expect.not.objectContaining({
+ mediaUrl: "https://example.com/long.png",
+ }),
+ );
+ expect(result).toEqual({ ok: true, messageId: "mid-2e-2" });
});
it("delegates link helper to JS transport", async () => {
diff --git a/extensions/zalouser/src/send.ts b/extensions/zalouser/src/send.ts
index 07ae1408bff..55ff17df636 100644
--- a/extensions/zalouser/src/send.ts
+++ b/extensions/zalouser/src/send.ts
@@ -1,3 +1,4 @@
+import { parseZalouserTextStyles } from "./text-styles.js";
import type { ZaloEventMessage, ZaloSendOptions, ZaloSendResult } from "./types.js";
import {
sendZaloDeliveredEvent,
@@ -7,16 +8,58 @@ import {
sendZaloTextMessage,
sendZaloTypingEvent,
} from "./zalo-js.js";
+import { TextStyle } from "./zca-client.js";
export type ZalouserSendOptions = ZaloSendOptions;
export type ZalouserSendResult = ZaloSendResult;
+const ZALO_TEXT_LIMIT = 2000;
+const DEFAULT_TEXT_CHUNK_MODE = "length";
+
+type StyledTextChunk = {
+ text: string;
+ styles?: ZaloSendOptions["textStyles"];
+};
+
+type TextChunkMode = NonNullable;
+
export async function sendMessageZalouser(
threadId: string,
text: string,
options: ZalouserSendOptions = {},
): Promise {
- return await sendZaloTextMessage(threadId, text, options);
+ const prepared =
+ options.textMode === "markdown"
+ ? parseZalouserTextStyles(text)
+ : { text, styles: options.textStyles };
+ const textChunkLimit = options.textChunkLimit ?? ZALO_TEXT_LIMIT;
+ const chunks = splitStyledText(
+ prepared.text,
+ (prepared.styles?.length ?? 0) > 0 ? prepared.styles : undefined,
+ textChunkLimit,
+ options.textChunkMode,
+ );
+
+ let lastResult: ZalouserSendResult | null = null;
+ for (const [index, chunk] of chunks.entries()) {
+ const chunkOptions =
+ index === 0
+ ? { ...options, textStyles: chunk.styles }
+ : {
+ ...options,
+ caption: undefined,
+ mediaLocalRoots: undefined,
+ mediaUrl: undefined,
+ textStyles: chunk.styles,
+ };
+ const result = await sendZaloTextMessage(threadId, chunk.text, chunkOptions);
+ if (!result.ok) {
+ return result;
+ }
+ lastResult = result;
+ }
+
+ return lastResult ?? { ok: false, error: "No message content provided" };
}
export async function sendImageZalouser(
@@ -24,8 +67,9 @@ export async function sendImageZalouser(
imageUrl: string,
options: ZalouserSendOptions = {},
): Promise {
- return await sendZaloTextMessage(threadId, options.caption ?? "", {
+ return await sendMessageZalouser(threadId, options.caption ?? "", {
...options,
+ caption: undefined,
mediaUrl: imageUrl,
});
}
@@ -85,3 +129,144 @@ export async function sendSeenZalouser(params: {
}): Promise {
await sendZaloSeenEvent(params);
}
+
+function splitStyledText(
+ text: string,
+ styles: ZaloSendOptions["textStyles"],
+ limit: number,
+ mode: ZaloSendOptions["textChunkMode"],
+): StyledTextChunk[] {
+ if (text.length === 0) {
+ return [{ text, styles: undefined }];
+ }
+
+ const chunks: StyledTextChunk[] = [];
+ for (const range of splitTextRanges(text, limit, mode ?? DEFAULT_TEXT_CHUNK_MODE)) {
+ const { start, end } = range;
+ chunks.push({
+ text: text.slice(start, end),
+ styles: sliceTextStyles(styles, start, end),
+ });
+ }
+ return chunks;
+}
+
+function sliceTextStyles(
+ styles: ZaloSendOptions["textStyles"],
+ start: number,
+ end: number,
+): ZaloSendOptions["textStyles"] {
+ if (!styles || styles.length === 0) {
+ return undefined;
+ }
+
+ const chunkStyles = styles
+ .map((style) => {
+ const overlapStart = Math.max(style.start, start);
+ const overlapEnd = Math.min(style.start + style.len, end);
+ if (overlapEnd <= overlapStart) {
+ return null;
+ }
+
+ if (style.st === TextStyle.Indent) {
+ return {
+ start: overlapStart - start,
+ len: overlapEnd - overlapStart,
+ st: style.st,
+ indentSize: style.indentSize,
+ };
+ }
+
+ return {
+ start: overlapStart - start,
+ len: overlapEnd - overlapStart,
+ st: style.st,
+ };
+ })
+ .filter((style): style is NonNullable => style !== null);
+
+ return chunkStyles.length > 0 ? chunkStyles : undefined;
+}
+
+function splitTextRanges(
+ text: string,
+ limit: number,
+ mode: TextChunkMode,
+): Array<{ start: number; end: number }> {
+ if (mode === "newline") {
+ return splitTextRangesByPreferredBreaks(text, limit);
+ }
+
+ const ranges: Array<{ start: number; end: number }> = [];
+ for (let start = 0; start < text.length; start += limit) {
+ ranges.push({
+ start,
+ end: Math.min(text.length, start + limit),
+ });
+ }
+ return ranges;
+}
+
+function splitTextRangesByPreferredBreaks(
+ text: string,
+ limit: number,
+): Array<{ start: number; end: number }> {
+ const ranges: Array<{ start: number; end: number }> = [];
+ let start = 0;
+
+ while (start < text.length) {
+ const maxEnd = Math.min(text.length, start + limit);
+ let end = maxEnd;
+ if (maxEnd < text.length) {
+ end =
+ findParagraphBreak(text, start, maxEnd) ??
+ findLastBreak(text, "\n", start, maxEnd) ??
+ findLastWhitespaceBreak(text, start, maxEnd) ??
+ maxEnd;
+ }
+
+ if (end <= start) {
+ end = maxEnd;
+ }
+
+ ranges.push({ start, end });
+ start = end;
+ }
+
+ return ranges;
+}
+
+function findParagraphBreak(text: string, start: number, end: number): number | undefined {
+ const slice = text.slice(start, end);
+ const matches = slice.matchAll(/\n[\t ]*\n+/g);
+ let lastMatch: RegExpMatchArray | undefined;
+ for (const match of matches) {
+ lastMatch = match;
+ }
+ if (!lastMatch || lastMatch.index === undefined) {
+ return undefined;
+ }
+ return start + lastMatch.index + lastMatch[0].length;
+}
+
+function findLastBreak(
+ text: string,
+ marker: string,
+ start: number,
+ end: number,
+): number | undefined {
+ const index = text.lastIndexOf(marker, end - 1);
+ if (index < start) {
+ return undefined;
+ }
+ return index + marker.length;
+}
+
+function findLastWhitespaceBreak(text: string, start: number, end: number): number | undefined {
+ for (let index = end - 1; index > start; index -= 1) {
+ if (/\s/.test(text[index])) {
+ return index + 1;
+ }
+ }
+ return undefined;
+}
diff --git a/extensions/zalouser/src/text-styles.test.ts b/extensions/zalouser/src/text-styles.test.ts
new file mode 100644
index 00000000000..01e6c2da86b
--- /dev/null
+++ b/extensions/zalouser/src/text-styles.test.ts
@@ -0,0 +1,203 @@
+import { describe, expect, it } from "vitest";
+import { parseZalouserTextStyles } from "./text-styles.js";
+import { TextStyle } from "./zca-client.js";
+
+describe("parseZalouserTextStyles", () => {
+ it("renders inline markdown emphasis as Zalo style ranges", () => {
+ expect(parseZalouserTextStyles("**bold** *italic* ~~strike~~")).toEqual({
+ text: "bold italic strike",
+ styles: [
+ { start: 0, len: 4, st: TextStyle.Bold },
+ { start: 5, len: 6, st: TextStyle.Italic },
+ { start: 12, len: 6, st: TextStyle.StrikeThrough },
+ ],
+ });
+ });
+
+ it("keeps inline code and plain math markers literal", () => {
+ expect(parseZalouserTextStyles("before `inline *code*` after\n2 * 3 * 4")).toEqual({
+ text: "before `inline *code*` after\n2 * 3 * 4",
+ styles: [],
+ });
+ });
+
+ it("preserves backslash escapes inside code spans and fenced code blocks", () => {
+ expect(parseZalouserTextStyles("before `\\*` after\n```ts\n\\*\\_\\\\\n```")).toEqual({
+ text: "before `\\*` after\n\\*\\_\\\\",
+ styles: [],
+ });
+ });
+
+ it("closes fenced code blocks when the input uses CRLF newlines", () => {
+ expect(parseZalouserTextStyles("```\r\n*code*\r\n```\r\n**after**")).toEqual({
+ text: "*code*\nafter",
+ styles: [{ start: 7, len: 5, st: TextStyle.Bold }],
+ });
+ });
+
+ it("maps headings, block quotes, and lists into line styles", () => {
+ expect(parseZalouserTextStyles(["# Title", "> quoted", " - nested"].join("\n"))).toEqual({
+ text: "Title\nquoted\nnested",
+ styles: [
+ { start: 0, len: 5, st: TextStyle.Bold },
+ { start: 0, len: 5, st: TextStyle.Big },
+ { start: 6, len: 6, st: TextStyle.Indent, indentSize: 1 },
+ { start: 13, len: 6, st: TextStyle.UnorderedList },
+ ],
+ });
+ });
+
+ it("treats 1-3 leading spaces as markdown padding for headings and lists", () => {
+ expect(parseZalouserTextStyles(" # Title\n 1. item\n - bullet")).toEqual({
+ text: "Title\nitem\nbullet",
+ styles: [
+ { start: 0, len: 5, st: TextStyle.Bold },
+ { start: 0, len: 5, st: TextStyle.Big },
+ { start: 6, len: 4, st: TextStyle.OrderedList },
+ { start: 11, len: 6, st: TextStyle.UnorderedList },
+ ],
+ });
+ });
+
+ it("strips fenced code markers and preserves leading indentation with nbsp", () => {
+ expect(parseZalouserTextStyles("```ts\n const x = 1\n\treturn x\n```")).toEqual({
+ text: "\u00A0\u00A0const x = 1\n\u00A0\u00A0\u00A0\u00A0return x",
+ styles: [],
+ });
+ });
+
+ it("treats tilde fences as literal code blocks", () => {
+ expect(parseZalouserTextStyles("~~~bash\n*cmd*\n~~~")).toEqual({
+ text: "*cmd*",
+ styles: [],
+ });
+ });
+
+ it("treats fences indented under list items as literal code blocks", () => {
+ expect(parseZalouserTextStyles(" ```\n*cmd*\n ```")).toEqual({
+ text: "*cmd*",
+ styles: [],
+ });
+ });
+
+ it("treats quoted backtick fences as literal code blocks", () => {
+ expect(parseZalouserTextStyles("> ```js\n> *cmd*\n> ```")).toEqual({
+ text: "*cmd*",
+ styles: [],
+ });
+ });
+
+ it("treats quoted tilde fences as literal code blocks", () => {
+ expect(parseZalouserTextStyles("> ~~~\n> *cmd*\n> ~~~")).toEqual({
+ text: "*cmd*",
+ styles: [],
+ });
+ });
+
+ it("preserves quote-prefixed lines inside normal fenced code blocks", () => {
+ expect(parseZalouserTextStyles("```\n> prompt\n```")).toEqual({
+ text: "> prompt",
+ styles: [],
+ });
+ });
+
+ it("does not treat quote-prefixed fence text inside code as a closing fence", () => {
+ expect(parseZalouserTextStyles("```\n> ```\n*still code*\n```")).toEqual({
+ text: "> ```\n*still code*",
+ styles: [],
+ });
+ });
+
+ it("treats indented blockquotes as quoted lines", () => {
+ expect(parseZalouserTextStyles(" > quoted")).toEqual({
+ text: "quoted",
+ styles: [{ start: 0, len: 6, st: TextStyle.Indent, indentSize: 1 }],
+ });
+ });
+
+ it("treats spaced nested blockquotes as deeper quoted lines", () => {
+ expect(parseZalouserTextStyles("> > quoted")).toEqual({
+ text: "quoted",
+ styles: [{ start: 0, len: 6, st: TextStyle.Indent, indentSize: 2 }],
+ });
+ });
+
+ it("treats indented quoted fences as literal code blocks", () => {
+ expect(parseZalouserTextStyles(" > ```\n > *cmd*\n > ```")).toEqual({
+ text: "*cmd*",
+ styles: [],
+ });
+ });
+
+ it("treats spaced nested quoted fences as literal code blocks", () => {
+ expect(parseZalouserTextStyles("> > ```\n> > code\n> > ```")).toEqual({
+ text: "code",
+ styles: [],
+ });
+ });
+
+ it("preserves inner quote markers inside quoted fenced code blocks", () => {
+ expect(parseZalouserTextStyles("> ```\n>> prompt\n> ```")).toEqual({
+ text: "> prompt",
+ styles: [],
+ });
+ });
+
+ it("keeps quote indentation on heading lines", () => {
+ expect(parseZalouserTextStyles("> # Title")).toEqual({
+ text: "Title",
+ styles: [
+ { start: 0, len: 5, st: TextStyle.Bold },
+ { start: 0, len: 5, st: TextStyle.Big },
+ { start: 0, len: 5, st: TextStyle.Indent, indentSize: 1 },
+ ],
+ });
+ });
+
+ it("keeps unmatched fences literal", () => {
+ expect(parseZalouserTextStyles("```python")).toEqual({
+ text: "```python",
+ styles: [],
+ });
+ });
+
+ it("keeps unclosed fenced blocks literal until eof", () => {
+ expect(parseZalouserTextStyles("```python\n\\*not italic*\n_next_")).toEqual({
+ text: "```python\n\\*not italic*\n_next_",
+ styles: [],
+ });
+ });
+
+ it("supports nested markdown and tag styles regardless of order", () => {
+ expect(parseZalouserTextStyles("**{red}x{/red}** {red}**y**{/red}")).toEqual({
+ text: "x y",
+ styles: [
+ { start: 0, len: 1, st: TextStyle.Bold },
+ { start: 0, len: 1, st: TextStyle.Red },
+ { start: 2, len: 1, st: TextStyle.Red },
+ { start: 2, len: 1, st: TextStyle.Bold },
+ ],
+ });
+ });
+
+ it("treats small text tags as normal text", () => {
+ expect(parseZalouserTextStyles("{small}tiny{/small}")).toEqual({
+ text: "tiny",
+ styles: [],
+ });
+ });
+
+ it("keeps escaped markers literal", () => {
+ expect(parseZalouserTextStyles("\\*literal\\* \\{underline}tag{/underline}")).toEqual({
+ text: "*literal* {underline}tag{/underline}",
+ styles: [],
+ });
+ });
+
+ it("keeps indented code blocks literal", () => {
+ expect(parseZalouserTextStyles(" *cmd*")).toEqual({
+ text: "\u00A0\u00A0\u00A0\u00A0*cmd*",
+ styles: [],
+ });
+ });
+});
diff --git a/extensions/zalouser/src/text-styles.ts b/extensions/zalouser/src/text-styles.ts
new file mode 100644
index 00000000000..cdfe8b492b5
--- /dev/null
+++ b/extensions/zalouser/src/text-styles.ts
@@ -0,0 +1,537 @@
+import { TextStyle, type Style } from "./zca-client.js";
+
+type InlineStyle = (typeof TextStyle)[keyof typeof TextStyle];
+
+type LineStyle = {
+ lineIndex: number;
+ style: InlineStyle;
+ indentSize?: number;
+};
+
+type Segment = {
+ text: string;
+ styles: InlineStyle[];
+};
+
+type InlineMarker = {
+ pattern: RegExp;
+ extractText: (match: RegExpExecArray) => string;
+ resolveStyles?: (match: RegExpExecArray) => InlineStyle[];
+ literal?: boolean;
+};
+
+type ResolvedInlineMatch = {
+ match: RegExpExecArray;
+ marker: InlineMarker;
+ styles: InlineStyle[];
+ text: string;
+ priority: number;
+};
+
+type FenceMarker = {
+ char: "`" | "~";
+ length: number;
+ indent: number;
+};
+
+type ActiveFence = FenceMarker & {
+ quoteIndent: number;
+};
+
+const TAG_STYLE_MAP: Record = {
+ red: TextStyle.Red,
+ orange: TextStyle.Orange,
+ yellow: TextStyle.Yellow,
+ green: TextStyle.Green,
+ small: null,
+ big: TextStyle.Big,
+ underline: TextStyle.Underline,
+};
+
+const INLINE_MARKERS: InlineMarker[] = [
+ {
+ pattern: /`([^`\n]+)`/g,
+ extractText: (match) => match[0],
+ literal: true,
+ },
+ {
+ pattern: /\\([*_~#\\{}>+\-`])/g,
+ extractText: (match) => match[1],
+ literal: true,
+ },
+ {
+ pattern: new RegExp(`\\{(${Object.keys(TAG_STYLE_MAP).join("|")})\\}(.+?)\\{/\\1\\}`, "g"),
+ extractText: (match) => match[2],
+ resolveStyles: (match) => {
+ const style = TAG_STYLE_MAP[match[1]];
+ return style ? [style] : [];
+ },
+ },
+ {
+ pattern: /(? match[1],
+ resolveStyles: () => [TextStyle.Bold, TextStyle.Italic],
+ },
+ {
+ pattern: /(? match[1],
+ resolveStyles: () => [TextStyle.Bold],
+ },
+ {
+ pattern: /(? match[1],
+ resolveStyles: () => [TextStyle.Bold],
+ },
+ {
+ pattern: /(? match[1],
+ resolveStyles: () => [TextStyle.StrikeThrough],
+ },
+ {
+ pattern: /(? match[1],
+ resolveStyles: () => [TextStyle.Italic],
+ },
+ {
+ pattern: /(? match[1],
+ resolveStyles: () => [TextStyle.Italic],
+ },
+];
+
+export function parseZalouserTextStyles(input: string): { text: string; styles: Style[] } {
+ const allStyles: Style[] = [];
+
+ const escapeMap: string[] = [];
+ const lines = input.replace(/\r\n?/g, "\n").split("\n");
+ const lineStyles: LineStyle[] = [];
+ const processedLines: string[] = [];
+ let activeFence: ActiveFence | null = null;
+
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
+ const rawLine = lines[lineIndex];
+ const { text: unquotedLine, indent: baseIndent } = stripQuotePrefix(rawLine);
+
+ if (activeFence) {
+ const codeLine =
+ activeFence.quoteIndent > 0
+ ? stripQuotePrefix(rawLine, activeFence.quoteIndent).text
+ : rawLine;
+ if (isClosingFence(codeLine, activeFence)) {
+ activeFence = null;
+ continue;
+ }
+ processedLines.push(
+ escapeLiteralText(
+ normalizeCodeBlockLeadingWhitespace(stripCodeFenceIndent(codeLine, activeFence.indent)),
+ escapeMap,
+ ),
+ );
+ continue;
+ }
+
+ let line = unquotedLine;
+ const openingFence = resolveOpeningFence(rawLine);
+ if (openingFence) {
+ const fenceLine = openingFence.quoteIndent > 0 ? unquotedLine : rawLine;
+ if (!hasClosingFence(lines, lineIndex + 1, openingFence)) {
+ processedLines.push(escapeLiteralText(fenceLine, escapeMap));
+ activeFence = openingFence;
+ continue;
+ }
+ activeFence = openingFence;
+ continue;
+ }
+
+ const outputLineIndex = processedLines.length;
+ if (isIndentedCodeBlockLine(line)) {
+ if (baseIndent > 0) {
+ lineStyles.push({
+ lineIndex: outputLineIndex,
+ style: TextStyle.Indent,
+ indentSize: baseIndent,
+ });
+ }
+ processedLines.push(escapeLiteralText(normalizeCodeBlockLeadingWhitespace(line), escapeMap));
+ continue;
+ }
+
+ const { text: markdownLine, size: markdownPadding } = stripOptionalMarkdownPadding(line);
+
+ const headingMatch = markdownLine.match(/^(#{1,4})\s(.*)$/);
+ if (headingMatch) {
+ const depth = headingMatch[1].length;
+ lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.Bold });
+ if (depth === 1) {
+ lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.Big });
+ }
+ if (baseIndent > 0) {
+ lineStyles.push({
+ lineIndex: outputLineIndex,
+ style: TextStyle.Indent,
+ indentSize: baseIndent,
+ });
+ }
+ processedLines.push(headingMatch[2]);
+ continue;
+ }
+
+ const indentMatch = markdownLine.match(/^(\s+)(.*)$/);
+ let indentLevel = 0;
+ let content = markdownLine;
+ if (indentMatch) {
+ indentLevel = clampIndent(indentMatch[1].length);
+ content = indentMatch[2];
+ }
+ const totalIndent = Math.min(5, baseIndent + indentLevel);
+
+ if (/^[-*+]\s\[[ xX]\]\s/.test(content)) {
+ if (totalIndent > 0) {
+ lineStyles.push({
+ lineIndex: outputLineIndex,
+ style: TextStyle.Indent,
+ indentSize: totalIndent,
+ });
+ }
+ processedLines.push(content);
+ continue;
+ }
+
+ const orderedListMatch = content.match(/^(\d+)\.\s(.*)$/);
+ if (orderedListMatch) {
+ if (totalIndent > 0) {
+ lineStyles.push({
+ lineIndex: outputLineIndex,
+ style: TextStyle.Indent,
+ indentSize: totalIndent,
+ });
+ }
+ lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.OrderedList });
+ processedLines.push(orderedListMatch[2]);
+ continue;
+ }
+
+ const unorderedListMatch = content.match(/^[-*+]\s(.*)$/);
+ if (unorderedListMatch) {
+ if (totalIndent > 0) {
+ lineStyles.push({
+ lineIndex: outputLineIndex,
+ style: TextStyle.Indent,
+ indentSize: totalIndent,
+ });
+ }
+ lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.UnorderedList });
+ processedLines.push(unorderedListMatch[1]);
+ continue;
+ }
+
+ if (markdownPadding > 0) {
+ if (baseIndent > 0) {
+ lineStyles.push({
+ lineIndex: outputLineIndex,
+ style: TextStyle.Indent,
+ indentSize: baseIndent,
+ });
+ }
+ processedLines.push(line);
+ continue;
+ }
+
+ if (totalIndent > 0) {
+ lineStyles.push({
+ lineIndex: outputLineIndex,
+ style: TextStyle.Indent,
+ indentSize: totalIndent,
+ });
+ processedLines.push(content);
+ continue;
+ }
+
+ processedLines.push(line);
+ }
+
+ const segments = parseInlineSegments(processedLines.join("\n"));
+
+ let plainText = "";
+ for (const segment of segments) {
+ const start = plainText.length;
+ plainText += segment.text;
+ for (const style of segment.styles) {
+ allStyles.push({ start, len: segment.text.length, st: style } as Style);
+ }
+ }
+
+ if (escapeMap.length > 0) {
+ const escapeRegex = /\x01(\d+)\x02/g;
+ const shifts: Array<{ pos: number; delta: number }> = [];
+ let cumulativeDelta = 0;
+
+ for (const match of plainText.matchAll(escapeRegex)) {
+ const escapeIndex = Number.parseInt(match[1], 10);
+ cumulativeDelta += match[0].length - escapeMap[escapeIndex].length;
+ shifts.push({ pos: (match.index ?? 0) + match[0].length, delta: cumulativeDelta });
+ }
+
+ for (const style of allStyles) {
+ let startDelta = 0;
+ let endDelta = 0;
+ const end = style.start + style.len;
+ for (const shift of shifts) {
+ if (shift.pos <= style.start) {
+ startDelta = shift.delta;
+ }
+ if (shift.pos <= end) {
+ endDelta = shift.delta;
+ }
+ }
+ style.start -= startDelta;
+ style.len -= endDelta - startDelta;
+ }
+
+ plainText = plainText.replace(
+ escapeRegex,
+ (_match, index) => escapeMap[Number.parseInt(index, 10)],
+ );
+ }
+
+ const finalLines = plainText.split("\n");
+ let offset = 0;
+ for (let lineIndex = 0; lineIndex < finalLines.length; lineIndex += 1) {
+ const lineLength = finalLines[lineIndex].length;
+ if (lineLength > 0) {
+ for (const lineStyle of lineStyles) {
+ if (lineStyle.lineIndex !== lineIndex) {
+ continue;
+ }
+
+ if (lineStyle.style === TextStyle.Indent) {
+ allStyles.push({
+ start: offset,
+ len: lineLength,
+ st: TextStyle.Indent,
+ indentSize: lineStyle.indentSize,
+ });
+ } else {
+ allStyles.push({ start: offset, len: lineLength, st: lineStyle.style } as Style);
+ }
+ }
+ }
+ offset += lineLength + 1;
+ }
+
+ return { text: plainText, styles: allStyles };
+}
+
+function clampIndent(spaceCount: number): number {
+ return Math.min(5, Math.max(1, Math.floor(spaceCount / 2)));
+}
+
+function stripOptionalMarkdownPadding(line: string): { text: string; size: number } {
+ const match = line.match(/^( {1,3})(?=\S)/);
+ if (!match) {
+ return { text: line, size: 0 };
+ }
+ return {
+ text: line.slice(match[1].length),
+ size: match[1].length,
+ };
+}
+
+function hasClosingFence(lines: string[], startIndex: number, fence: ActiveFence): boolean {
+ for (let index = startIndex; index < lines.length; index += 1) {
+ const candidate =
+ fence.quoteIndent > 0 ? stripQuotePrefix(lines[index], fence.quoteIndent).text : lines[index];
+ if (isClosingFence(candidate, fence)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function resolveOpeningFence(line: string): ActiveFence | null {
+ const directFence = parseFenceMarker(line);
+ if (directFence) {
+ return { ...directFence, quoteIndent: 0 };
+ }
+
+ const quoted = stripQuotePrefix(line);
+ if (quoted.indent === 0) {
+ return null;
+ }
+
+ const quotedFence = parseFenceMarker(quoted.text);
+ if (!quotedFence) {
+ return null;
+ }
+
+ return {
+ ...quotedFence,
+ quoteIndent: quoted.indent,
+ };
+}
+
+function stripQuotePrefix(
+ line: string,
+ maxDepth = Number.POSITIVE_INFINITY,
+): { text: string; indent: number } {
+ let cursor = 0;
+ while (cursor < line.length && cursor < 3 && line[cursor] === " ") {
+ cursor += 1;
+ }
+
+ let removedDepth = 0;
+ let consumedCursor = cursor;
+ while (removedDepth < maxDepth && consumedCursor < line.length && line[consumedCursor] === ">") {
+ removedDepth += 1;
+ consumedCursor += 1;
+ if (line[consumedCursor] === " ") {
+ consumedCursor += 1;
+ }
+ }
+
+ if (removedDepth === 0) {
+ return { text: line, indent: 0 };
+ }
+
+ return {
+ text: line.slice(consumedCursor),
+ indent: Math.min(5, removedDepth),
+ };
+}
+
+function parseFenceMarker(line: string): FenceMarker | null {
+ const match = line.match(/^([ ]{0,3})(`{3,}|~{3,})(.*)$/);
+ if (!match) {
+ return null;
+ }
+
+ const marker = match[2];
+ const char = marker[0];
+ if (char !== "`" && char !== "~") {
+ return null;
+ }
+
+ return {
+ char,
+ length: marker.length,
+ indent: match[1].length,
+ };
+}
+
+function isClosingFence(line: string, fence: FenceMarker): boolean {
+ const match = line.match(/^([ ]{0,3})(`{3,}|~{3,})[ \t]*$/);
+ if (!match) {
+ return false;
+ }
+ return match[2][0] === fence.char && match[2].length >= fence.length;
+}
+
+function escapeLiteralText(input: string, escapeMap: string[]): string {
+ return input.replace(/[\\*_~{}`]/g, (ch) => {
+ const index = escapeMap.length;
+ escapeMap.push(ch);
+ return `\x01${index}\x02`;
+ });
+}
+
+function parseInlineSegments(text: string, inheritedStyles: InlineStyle[] = []): Segment[] {
+ const segments: Segment[] = [];
+ let cursor = 0;
+
+ while (cursor < text.length) {
+ const nextMatch = findNextInlineMatch(text, cursor);
+ if (!nextMatch) {
+ pushSegment(segments, text.slice(cursor), inheritedStyles);
+ break;
+ }
+
+ if (nextMatch.match.index > cursor) {
+ pushSegment(segments, text.slice(cursor, nextMatch.match.index), inheritedStyles);
+ }
+
+ const combinedStyles = [...inheritedStyles, ...nextMatch.styles];
+ if (nextMatch.marker.literal) {
+ pushSegment(segments, nextMatch.text, combinedStyles);
+ } else {
+ segments.push(...parseInlineSegments(nextMatch.text, combinedStyles));
+ }
+
+ cursor = nextMatch.match.index + nextMatch.match[0].length;
+ }
+
+ return segments;
+}
+
+function findNextInlineMatch(text: string, startIndex: number): ResolvedInlineMatch | null {
+ let bestMatch: ResolvedInlineMatch | null = null;
+
+ for (const [priority, marker] of INLINE_MARKERS.entries()) {
+ const regex = new RegExp(marker.pattern.source, marker.pattern.flags);
+ regex.lastIndex = startIndex;
+ const match = regex.exec(text);
+ if (!match) {
+ continue;
+ }
+
+ if (
+ bestMatch &&
+ (match.index > bestMatch.match.index ||
+ (match.index === bestMatch.match.index && priority > bestMatch.priority))
+ ) {
+ continue;
+ }
+
+ bestMatch = {
+ match,
+ marker,
+ text: marker.extractText(match),
+ styles: marker.resolveStyles?.(match) ?? [],
+ priority,
+ };
+ }
+
+ return bestMatch;
+}
+
+function pushSegment(segments: Segment[], text: string, styles: InlineStyle[]): void {
+ if (!text) {
+ return;
+ }
+
+ const lastSegment = segments.at(-1);
+ if (lastSegment && sameStyles(lastSegment.styles, styles)) {
+ lastSegment.text += text;
+ return;
+ }
+
+ segments.push({
+ text,
+ styles: [...styles],
+ });
+}
+
+function sameStyles(left: InlineStyle[], right: InlineStyle[]): boolean {
+ return left.length === right.length && left.every((style, index) => style === right[index]);
+}
+
+function normalizeCodeBlockLeadingWhitespace(line: string): string {
+ return line.replace(/^[ \t]+/, (leadingWhitespace) =>
+ leadingWhitespace.replace(/\t/g, "\u00A0\u00A0\u00A0\u00A0").replace(/ /g, "\u00A0"),
+ );
+}
+
+function isIndentedCodeBlockLine(line: string): boolean {
+ return /^(?: {4,}|\t)/.test(line);
+}
+
+function stripCodeFenceIndent(line: string, indent: number): string {
+ let consumed = 0;
+ let cursor = 0;
+
+ while (cursor < line.length && consumed < indent && line[cursor] === " ") {
+ cursor += 1;
+ consumed += 1;
+ }
+
+ return line.slice(cursor);
+}
diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts
index d704a1b3f78..e6343b1f6bd 100644
--- a/extensions/zalouser/src/types.ts
+++ b/extensions/zalouser/src/types.ts
@@ -1,3 +1,5 @@
+import type { Style } from "./zca-client.js";
+
export type ZcaFriend = {
userId: string;
displayName: string;
@@ -59,6 +61,10 @@ export type ZaloSendOptions = {
caption?: string;
isGroup?: boolean;
mediaLocalRoots?: readonly string[];
+ textMode?: "markdown" | "plain";
+ textChunkMode?: "length" | "newline";
+ textChunkLimit?: number;
+ textStyles?: Style[];
};
export type ZaloSendResult = {
diff --git a/extensions/zalouser/src/zalo-js.ts b/extensions/zalouser/src/zalo-js.ts
index 25d263b7d6a..0e2d744232f 100644
--- a/extensions/zalouser/src/zalo-js.ts
+++ b/extensions/zalouser/src/zalo-js.ts
@@ -20,6 +20,7 @@ import type {
} from "./types.js";
import {
LoginQRCallbackEventType,
+ TextStyle,
ThreadType,
Zalo,
type API,
@@ -136,6 +137,39 @@ function toErrorMessage(error: unknown): string {
return String(error);
}
+function clampTextStyles(
+ text: string,
+ styles?: ZaloSendOptions["textStyles"],
+): ZaloSendOptions["textStyles"] {
+ if (!styles || styles.length === 0) {
+ return undefined;
+ }
+ const maxLength = text.length;
+ const clamped = styles
+ .map((style) => {
+ const start = Math.max(0, Math.min(style.start, maxLength));
+ const end = Math.min(style.start + style.len, maxLength);
+ if (end <= start) {
+ return null;
+ }
+ if (style.st === TextStyle.Indent) {
+ return {
+ start,
+ len: end - start,
+ st: style.st,
+ indentSize: style.indentSize,
+ };
+ }
+ return {
+ start,
+ len: end - start,
+ st: style.st,
+ };
+ })
+ .filter((style): style is NonNullable => style !== null);
+ return clamped.length > 0 ? clamped : undefined;
+}
+
function toNumberId(value: unknown): string {
if (typeof value === "number" && Number.isFinite(value)) {
return String(Math.trunc(value));
@@ -1018,11 +1052,16 @@ export async function sendZaloTextMessage(
kind: media.kind,
});
const payloadText = (text || options.caption || "").slice(0, 2000);
+ const textStyles = clampTextStyles(payloadText, options.textStyles);
if (media.kind === "audio") {
let textMessageId: string | undefined;
if (payloadText) {
- const textResponse = await api.sendMessage(payloadText, trimmedThreadId, type);
+ const textResponse = await api.sendMessage(
+ textStyles ? { msg: payloadText, styles: textStyles } : payloadText,
+ trimmedThreadId,
+ type,
+ );
textMessageId = extractSendMessageId(textResponse);
}
@@ -1055,6 +1094,7 @@ export async function sendZaloTextMessage(
const response = await api.sendMessage(
{
msg: payloadText,
+ ...(textStyles ? { styles: textStyles } : {}),
attachments: [
{
data: media.buffer,
@@ -1071,7 +1111,13 @@ export async function sendZaloTextMessage(
return { ok: true, messageId: extractSendMessageId(response) };
}
- const response = await api.sendMessage(text.slice(0, 2000), trimmedThreadId, type);
+ const payloadText = text.slice(0, 2000);
+ const textStyles = clampTextStyles(payloadText, options.textStyles);
+ const response = await api.sendMessage(
+ textStyles ? { msg: payloadText, styles: textStyles } : payloadText,
+ trimmedThreadId,
+ type,
+ );
return { ok: true, messageId: extractSendMessageId(response) };
} catch (error) {
return { ok: false, error: toErrorMessage(error) };
diff --git a/extensions/zalouser/src/zca-client.ts b/extensions/zalouser/src/zca-client.ts
index 57172eef64d..00a1c8c1be0 100644
--- a/extensions/zalouser/src/zca-client.ts
+++ b/extensions/zalouser/src/zca-client.ts
@@ -28,6 +28,39 @@ export const Reactions = ReactionsRuntime as Record & {
NONE: string;
};
+// Mirror zca-js sendMessage style constants locally because the package root
+// typing surface does not consistently expose TextStyle/Style to tsgo.
+export const TextStyle = {
+ Bold: "b",
+ Italic: "i",
+ Underline: "u",
+ StrikeThrough: "s",
+ Red: "c_db342e",
+ Orange: "c_f27806",
+ Yellow: "c_f7b503",
+ Green: "c_15a85f",
+ Small: "f_13",
+ Big: "f_18",
+ UnorderedList: "lst_1",
+ OrderedList: "lst_2",
+ Indent: "ind_$",
+} as const;
+
+type TextStyleValue = (typeof TextStyle)[keyof typeof TextStyle];
+
+export type Style =
+ | {
+ start: number;
+ len: number;
+ st: Exclude;
+ }
+ | {
+ start: number;
+ len: number;
+ st: typeof TextStyle.Indent;
+ indentSize?: number;
+ };
+
export type Credentials = {
imei: string;
cookie: unknown;
diff --git a/package.json b/package.json
index d84428da146..9c1100bc49f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openclaw",
- "version": "2026.3.11-beta.1",
+ "version": "2026.3.11",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",
diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts
index db01c03d8c4..1548ce5496a 100644
--- a/src/agents/failover-error.test.ts
+++ b/src/agents/failover-error.test.ts
@@ -274,6 +274,8 @@ describe("failover-error", () => {
it("infers timeout from common node error codes", () => {
expect(resolveFailoverReasonFromError({ code: "ETIMEDOUT" })).toBe("timeout");
expect(resolveFailoverReasonFromError({ code: "ECONNRESET" })).toBe("timeout");
+ expect(resolveFailoverReasonFromError({ code: "EHOSTDOWN" })).toBe("timeout");
+ expect(resolveFailoverReasonFromError({ code: "EPIPE" })).toBe("timeout");
});
it("infers timeout from abort/error stop-reason messages", () => {
diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts
index a39685e1b16..8c49df40acb 100644
--- a/src/agents/failover-error.ts
+++ b/src/agents/failover-error.ts
@@ -170,7 +170,9 @@ export function resolveFailoverReasonFromError(err: unknown): FailoverReason | n
"ECONNREFUSED",
"ENETUNREACH",
"EHOSTUNREACH",
+ "EHOSTDOWN",
"ENETRESET",
+ "EPIPE",
"EAI_AGAIN",
].includes(code)
) {
diff --git a/src/agents/models-config.plan.ts b/src/agents/models-config.plan.ts
index 40777c2cd0d..601a0edfda1 100644
--- a/src/agents/models-config.plan.ts
+++ b/src/agents/models-config.plan.ts
@@ -6,6 +6,7 @@ import {
type ExistingProviderConfig,
} from "./models-config.merge.js";
import {
+ enforceSourceManagedProviderSecrets,
normalizeProviders,
resolveImplicitProviders,
type ProviderConfig,
@@ -86,6 +87,7 @@ async function resolveProvidersForMode(params: {
export async function planOpenClawModelsJson(params: {
cfg: OpenClawConfig;
+ sourceConfigForSecrets?: OpenClawConfig;
agentDir: string;
env: NodeJS.ProcessEnv;
existingRaw: string;
@@ -106,6 +108,8 @@ export async function planOpenClawModelsJson(params: {
agentDir,
env,
secretDefaults: cfg.secrets?.defaults,
+ sourceProviders: params.sourceConfigForSecrets?.models?.providers,
+ sourceSecretDefaults: params.sourceConfigForSecrets?.secrets?.defaults,
secretRefManagedProviders,
}) ?? providers;
const mergedProviders = await resolveProvidersForMode({
@@ -115,7 +119,14 @@ export async function planOpenClawModelsJson(params: {
secretRefManagedProviders,
explicitBaseUrlProviders: resolveExplicitBaseUrlProviders(cfg.models),
});
- const nextContents = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`;
+ const secretEnforcedProviders =
+ enforceSourceManagedProviderSecrets({
+ providers: mergedProviders,
+ sourceProviders: params.sourceConfigForSecrets?.models?.providers,
+ sourceSecretDefaults: params.sourceConfigForSecrets?.secrets?.defaults,
+ secretRefManagedProviders,
+ }) ?? mergedProviders;
+ const nextContents = `${JSON.stringify({ providers: secretEnforcedProviders }, null, 2)}\n`;
if (params.existingRaw === nextContents) {
return { action: "noop" };
diff --git a/src/agents/models-config.providers.normalize-keys.test.ts b/src/agents/models-config.providers.normalize-keys.test.ts
index f8422d797dd..b39705d8ec2 100644
--- a/src/agents/models-config.providers.normalize-keys.test.ts
+++ b/src/agents/models-config.providers.normalize-keys.test.ts
@@ -4,7 +4,10 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
-import { normalizeProviders } from "./models-config.providers.js";
+import {
+ enforceSourceManagedProviderSecrets,
+ normalizeProviders,
+} from "./models-config.providers.js";
describe("normalizeProviders", () => {
it("trims provider keys so image models remain discoverable for custom providers", async () => {
@@ -136,4 +139,38 @@ describe("normalizeProviders", () => {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
+
+ it("ignores non-object provider entries during source-managed enforcement", () => {
+ const providers = {
+ openai: null,
+ moonshot: {
+ baseUrl: "https://api.moonshot.ai/v1",
+ api: "openai-completions",
+ apiKey: "sk-runtime-moonshot", // pragma: allowlist secret
+ models: [],
+ },
+ } as unknown as NonNullable["providers"]>;
+
+ const sourceProviders: NonNullable["providers"]> = {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ api: "openai-completions",
+ apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret
+ models: [],
+ },
+ moonshot: {
+ baseUrl: "https://api.moonshot.ai/v1",
+ api: "openai-completions",
+ apiKey: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" }, // pragma: allowlist secret
+ models: [],
+ },
+ };
+
+ const enforced = enforceSourceManagedProviderSecrets({
+ providers,
+ sourceProviders,
+ });
+ expect((enforced as Record).openai).toBeNull();
+ expect(enforced?.moonshot?.apiKey).toBe("MOONSHOT_API_KEY"); // pragma: allowlist secret
+ });
});
diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts
index c63ed6865a8..411072f2d7a 100644
--- a/src/agents/models-config.providers.ts
+++ b/src/agents/models-config.providers.ts
@@ -4,6 +4,7 @@ import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
+import { isRecord } from "../utils.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { discoverBedrockModels } from "./bedrock-discovery.js";
@@ -70,6 +71,11 @@ export { resolveOllamaApiBase } from "./models-config.providers.discovery.js";
type ModelsConfig = NonNullable;
export type ProviderConfig = NonNullable[string];
+type SecretDefaults = {
+ env?: string;
+ file?: string;
+ exec?: string;
+};
const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
@@ -97,13 +103,7 @@ function resolveAwsSdkApiKeyVarName(env: NodeJS.ProcessEnv = process.env): strin
function normalizeHeaderValues(params: {
headers: ProviderConfig["headers"] | undefined;
- secretDefaults:
- | {
- env?: string;
- file?: string;
- exec?: string;
- }
- | undefined;
+ secretDefaults: SecretDefaults | undefined;
}): { headers: ProviderConfig["headers"] | undefined; mutated: boolean } {
const { headers } = params;
if (!headers) {
@@ -276,15 +276,155 @@ function normalizeAntigravityProvider(provider: ProviderConfig): ProviderConfig
return normalizeProviderModels(provider, normalizeAntigravityModelId);
}
+function normalizeSourceProviderLookup(
+ providers: ModelsConfig["providers"] | undefined,
+): Record {
+ if (!providers) {
+ return {};
+ }
+ const out: Record = {};
+ for (const [key, provider] of Object.entries(providers)) {
+ const normalizedKey = key.trim();
+ if (!normalizedKey || !isRecord(provider)) {
+ continue;
+ }
+ out[normalizedKey] = provider;
+ }
+ return out;
+}
+
+function resolveSourceManagedApiKeyMarker(params: {
+ sourceProvider: ProviderConfig | undefined;
+ sourceSecretDefaults: SecretDefaults | undefined;
+}): string | undefined {
+ const sourceApiKeyRef = resolveSecretInputRef({
+ value: params.sourceProvider?.apiKey,
+ defaults: params.sourceSecretDefaults,
+ }).ref;
+ if (!sourceApiKeyRef || !sourceApiKeyRef.id.trim()) {
+ return undefined;
+ }
+ return sourceApiKeyRef.source === "env"
+ ? sourceApiKeyRef.id.trim()
+ : resolveNonEnvSecretRefApiKeyMarker(sourceApiKeyRef.source);
+}
+
+function resolveSourceManagedHeaderMarkers(params: {
+ sourceProvider: ProviderConfig | undefined;
+ sourceSecretDefaults: SecretDefaults | undefined;
+}): Record {
+ const sourceHeaders = isRecord(params.sourceProvider?.headers)
+ ? (params.sourceProvider.headers as Record)
+ : undefined;
+ if (!sourceHeaders) {
+ return {};
+ }
+ const markers: Record = {};
+ for (const [headerName, headerValue] of Object.entries(sourceHeaders)) {
+ const sourceHeaderRef = resolveSecretInputRef({
+ value: headerValue,
+ defaults: params.sourceSecretDefaults,
+ }).ref;
+ if (!sourceHeaderRef || !sourceHeaderRef.id.trim()) {
+ continue;
+ }
+ markers[headerName] =
+ sourceHeaderRef.source === "env"
+ ? resolveEnvSecretRefHeaderValueMarker(sourceHeaderRef.id)
+ : resolveNonEnvSecretRefHeaderValueMarker(sourceHeaderRef.source);
+ }
+ return markers;
+}
+
+export function enforceSourceManagedProviderSecrets(params: {
+ providers: ModelsConfig["providers"];
+ sourceProviders: ModelsConfig["providers"] | undefined;
+ sourceSecretDefaults?: SecretDefaults;
+ secretRefManagedProviders?: Set;
+}): ModelsConfig["providers"] {
+ const { providers } = params;
+ if (!providers) {
+ return providers;
+ }
+ const sourceProvidersByKey = normalizeSourceProviderLookup(params.sourceProviders);
+ if (Object.keys(sourceProvidersByKey).length === 0) {
+ return providers;
+ }
+
+ let nextProviders: Record | null = null;
+ for (const [providerKey, provider] of Object.entries(providers)) {
+ if (!isRecord(provider)) {
+ continue;
+ }
+ const sourceProvider = sourceProvidersByKey[providerKey.trim()];
+ if (!sourceProvider) {
+ continue;
+ }
+ let nextProvider = provider;
+ let providerMutated = false;
+
+ const sourceApiKeyMarker = resolveSourceManagedApiKeyMarker({
+ sourceProvider,
+ sourceSecretDefaults: params.sourceSecretDefaults,
+ });
+ if (sourceApiKeyMarker) {
+ params.secretRefManagedProviders?.add(providerKey.trim());
+ if (nextProvider.apiKey !== sourceApiKeyMarker) {
+ providerMutated = true;
+ nextProvider = {
+ ...nextProvider,
+ apiKey: sourceApiKeyMarker,
+ };
+ }
+ }
+
+ const sourceHeaderMarkers = resolveSourceManagedHeaderMarkers({
+ sourceProvider,
+ sourceSecretDefaults: params.sourceSecretDefaults,
+ });
+ if (Object.keys(sourceHeaderMarkers).length > 0) {
+ const currentHeaders = isRecord(nextProvider.headers)
+ ? (nextProvider.headers as Record)
+ : undefined;
+ const nextHeaders = {
+ ...(currentHeaders as Record[string]>),
+ };
+ let headersMutated = !currentHeaders;
+ for (const [headerName, marker] of Object.entries(sourceHeaderMarkers)) {
+ if (nextHeaders[headerName] === marker) {
+ continue;
+ }
+ headersMutated = true;
+ nextHeaders[headerName] = marker;
+ }
+ if (headersMutated) {
+ providerMutated = true;
+ nextProvider = {
+ ...nextProvider,
+ headers: nextHeaders,
+ };
+ }
+ }
+
+ if (!providerMutated) {
+ continue;
+ }
+ if (!nextProviders) {
+ nextProviders = { ...providers };
+ }
+ nextProviders[providerKey] = nextProvider;
+ }
+
+ return nextProviders ?? providers;
+}
+
export function normalizeProviders(params: {
providers: ModelsConfig["providers"];
agentDir: string;
env?: NodeJS.ProcessEnv;
- secretDefaults?: {
- env?: string;
- file?: string;
- exec?: string;
- };
+ secretDefaults?: SecretDefaults;
+ sourceProviders?: ModelsConfig["providers"];
+ sourceSecretDefaults?: SecretDefaults;
secretRefManagedProviders?: Set;
}): ModelsConfig["providers"] {
const { providers } = params;
@@ -434,7 +574,13 @@ export function normalizeProviders(params: {
next[normalizedKey] = normalizedProvider;
}
- return mutated ? next : providers;
+ const normalizedProviders = mutated ? next : providers;
+ return enforceSourceManagedProviderSecrets({
+ providers: normalizedProviders,
+ sourceProviders: params.sourceProviders,
+ sourceSecretDefaults: params.sourceSecretDefaults,
+ secretRefManagedProviders: params.secretRefManagedProviders,
+ });
}
type ImplicitProviderParams = {
diff --git a/src/agents/models-config.runtime-source-snapshot.test.ts b/src/agents/models-config.runtime-source-snapshot.test.ts
index 4c5889769cc..cc033fb56a6 100644
--- a/src/agents/models-config.runtime-source-snapshot.test.ts
+++ b/src/agents/models-config.runtime-source-snapshot.test.ts
@@ -209,4 +209,152 @@ describe("models-config runtime source snapshot", () => {
}
});
});
+
+ it("keeps source markers when runtime projection is skipped for incompatible top-level shape", async () => {
+ await withTempHome(async () => {
+ const sourceConfig: OpenClawConfig = {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret
+ api: "openai-completions" as const,
+ models: [],
+ },
+ },
+ },
+ gateway: {
+ auth: {
+ mode: "token",
+ },
+ },
+ };
+ const runtimeConfig: OpenClawConfig = {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ apiKey: "sk-runtime-resolved", // pragma: allowlist secret
+ api: "openai-completions" as const,
+ models: [],
+ },
+ },
+ },
+ gateway: {
+ auth: {
+ mode: "token",
+ },
+ },
+ };
+ const incompatibleCandidate: OpenClawConfig = {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ apiKey: "sk-runtime-resolved", // pragma: allowlist secret
+ api: "openai-completions" as const,
+ models: [],
+ },
+ },
+ },
+ };
+
+ try {
+ setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
+ await ensureOpenClawModelsJson(incompatibleCandidate);
+
+ const parsed = await readGeneratedModelsJson<{
+ providers: Record;
+ }>();
+ expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
+ } finally {
+ clearRuntimeConfigSnapshot();
+ clearConfigCache();
+ }
+ });
+ });
+
+ it("keeps source header markers when runtime projection is skipped for incompatible top-level shape", async () => {
+ await withTempHome(async () => {
+ const sourceConfig: OpenClawConfig = {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ api: "openai-completions" as const,
+ headers: {
+ Authorization: {
+ source: "env",
+ provider: "default",
+ id: "OPENAI_HEADER_TOKEN", // pragma: allowlist secret
+ },
+ "X-Tenant-Token": {
+ source: "file",
+ provider: "vault",
+ id: "/providers/openai/tenantToken",
+ },
+ },
+ models: [],
+ },
+ },
+ },
+ gateway: {
+ auth: {
+ mode: "token",
+ },
+ },
+ };
+ const runtimeConfig: OpenClawConfig = {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ api: "openai-completions" as const,
+ headers: {
+ Authorization: "Bearer runtime-openai-token",
+ "X-Tenant-Token": "runtime-tenant-token",
+ },
+ models: [],
+ },
+ },
+ },
+ gateway: {
+ auth: {
+ mode: "token",
+ },
+ },
+ };
+ const incompatibleCandidate: OpenClawConfig = {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ api: "openai-completions" as const,
+ headers: {
+ Authorization: "Bearer runtime-openai-token",
+ "X-Tenant-Token": "runtime-tenant-token",
+ },
+ models: [],
+ },
+ },
+ },
+ };
+
+ try {
+ setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
+ await ensureOpenClawModelsJson(incompatibleCandidate);
+
+ const parsed = await readGeneratedModelsJson<{
+ providers: Record }>;
+ }>();
+ expect(parsed.providers.openai?.headers?.Authorization).toBe(
+ "secretref-env:OPENAI_HEADER_TOKEN", // pragma: allowlist secret
+ );
+ expect(parsed.providers.openai?.headers?.["X-Tenant-Token"]).toBe(NON_ENV_SECRETREF_MARKER);
+ } finally {
+ clearRuntimeConfigSnapshot();
+ clearConfigCache();
+ }
+ });
+ });
});
diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts
index 99714a1a792..3e013799b0b 100644
--- a/src/agents/models-config.ts
+++ b/src/agents/models-config.ts
@@ -42,15 +42,31 @@ async function writeModelsFileAtomic(targetPath: string, contents: string): Prom
await fs.rename(tempPath, targetPath);
}
-function resolveModelsConfigInput(config?: OpenClawConfig): OpenClawConfig {
+function resolveModelsConfigInput(config?: OpenClawConfig): {
+ config: OpenClawConfig;
+ sourceConfigForSecrets: OpenClawConfig;
+} {
const runtimeSource = getRuntimeConfigSourceSnapshot();
if (!config) {
- return runtimeSource ?? loadConfig();
+ const loaded = loadConfig();
+ return {
+ config: runtimeSource ?? loaded,
+ sourceConfigForSecrets: runtimeSource ?? loaded,
+ };
}
if (!runtimeSource) {
- return config;
+ return {
+ config,
+ sourceConfigForSecrets: config,
+ };
}
- return projectConfigOntoRuntimeSourceSnapshot(config);
+ const projected = projectConfigOntoRuntimeSourceSnapshot(config);
+ return {
+ config: projected,
+ // If projection is skipped (for example incompatible top-level shape),
+ // keep managed secret persistence anchored to the active source snapshot.
+ sourceConfigForSecrets: projected === config ? runtimeSource : projected,
+ };
}
async function withModelsJsonWriteLock(targetPath: string, run: () => Promise): Promise {
@@ -76,7 +92,8 @@ export async function ensureOpenClawModelsJson(
config?: OpenClawConfig,
agentDirOverride?: string,
): Promise<{ agentDir: string; wrote: boolean }> {
- const cfg = resolveModelsConfigInput(config);
+ const resolved = resolveModelsConfigInput(config);
+ const cfg = resolved.config;
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir();
const targetPath = path.join(agentDir, "models.json");
@@ -87,6 +104,7 @@ export async function ensureOpenClawModelsJson(
const existingModelsFile = await readExistingModelsFile(targetPath);
const plan = await planOpenClawModelsJson({
cfg,
+ sourceConfigForSecrets: resolved.sourceConfigForSecrets,
agentDir,
env,
existingRaw: existingModelsFile.raw,
diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts
index db45e8d48b8..193deb6304f 100644
--- a/src/agents/openclaw-tools.session-status.test.ts
+++ b/src/agents/openclaw-tools.session-status.test.ts
@@ -2,6 +2,22 @@ import { describe, expect, it, vi } from "vitest";
const loadSessionStoreMock = vi.fn();
const updateSessionStoreMock = vi.fn();
+const callGatewayMock = vi.fn();
+
+const createMockConfig = () => ({
+ session: { mainKey: "main", scope: "per-sender" },
+ agents: {
+ defaults: {
+ model: { primary: "anthropic/claude-opus-4-5" },
+ models: {},
+ },
+ },
+ tools: {
+ agentToAgent: { enabled: false },
+ },
+});
+
+let mockConfig: Record = createMockConfig();
vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal();
@@ -22,19 +38,15 @@ vi.mock("../config/sessions.js", async (importOriginal) => {
};
});
+vi.mock("../gateway/call.js", () => ({
+ callGateway: (opts: unknown) => callGatewayMock(opts),
+}));
+
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
- loadConfig: () => ({
- session: { mainKey: "main", scope: "per-sender" },
- agents: {
- defaults: {
- model: { primary: "anthropic/claude-opus-4-5" },
- models: {},
- },
- },
- }),
+ loadConfig: () => mockConfig,
};
});
@@ -82,13 +94,17 @@ import { createOpenClawTools } from "./openclaw-tools.js";
function resetSessionStore(store: Record) {
loadSessionStoreMock.mockClear();
updateSessionStoreMock.mockClear();
+ callGatewayMock.mockClear();
loadSessionStoreMock.mockReturnValue(store);
+ callGatewayMock.mockResolvedValue({});
+ mockConfig = createMockConfig();
}
-function getSessionStatusTool(agentSessionKey = "main") {
- const tool = createOpenClawTools({ agentSessionKey }).find(
- (candidate) => candidate.name === "session_status",
- );
+function getSessionStatusTool(agentSessionKey = "main", options?: { sandboxed?: boolean }) {
+ const tool = createOpenClawTools({
+ agentSessionKey,
+ sandboxed: options?.sandboxed,
+ }).find((candidate) => candidate.name === "session_status");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing session_status tool");
@@ -176,6 +192,153 @@ describe("session_status tool", () => {
);
});
+ it("blocks sandboxed child session_status access outside its tree before store lookup", async () => {
+ resetSessionStore({
+ "agent:main:subagent:child": {
+ sessionId: "s-child",
+ updatedAt: 20,
+ },
+ "agent:main:main": {
+ sessionId: "s-parent",
+ updatedAt: 10,
+ },
+ });
+ mockConfig = {
+ session: { mainKey: "main", scope: "per-sender" },
+ tools: {
+ sessions: { visibility: "all" },
+ agentToAgent: { enabled: true, allow: ["*"] },
+ },
+ agents: {
+ defaults: {
+ model: { primary: "anthropic/claude-opus-4-5" },
+ models: {},
+ sandbox: { sessionToolsVisibility: "spawned" },
+ },
+ },
+ };
+ callGatewayMock.mockImplementation(async (opts: unknown) => {
+ const request = opts as { method?: string; params?: Record };
+ if (request.method === "sessions.list") {
+ return { sessions: [] };
+ }
+ return {};
+ });
+
+ const tool = getSessionStatusTool("agent:main:subagent:child", {
+ sandboxed: true,
+ });
+ const expectedError = "Session status visibility is restricted to the current session tree";
+
+ await expect(
+ tool.execute("call6", {
+ sessionKey: "agent:main:main",
+ model: "anthropic/claude-sonnet-4-5",
+ }),
+ ).rejects.toThrow(expectedError);
+
+ await expect(
+ tool.execute("call7", {
+ sessionKey: "agent:main:subagent:missing",
+ }),
+ ).rejects.toThrow(expectedError);
+
+ expect(loadSessionStoreMock).not.toHaveBeenCalled();
+ expect(updateSessionStoreMock).not.toHaveBeenCalled();
+ expect(callGatewayMock).toHaveBeenCalledTimes(2);
+ expect(callGatewayMock).toHaveBeenNthCalledWith(1, {
+ method: "sessions.list",
+ params: {
+ includeGlobal: false,
+ includeUnknown: false,
+ limit: 500,
+ spawnedBy: "agent:main:subagent:child",
+ },
+ });
+ expect(callGatewayMock).toHaveBeenNthCalledWith(2, {
+ method: "sessions.list",
+ params: {
+ includeGlobal: false,
+ includeUnknown: false,
+ limit: 500,
+ spawnedBy: "agent:main:subagent:child",
+ },
+ });
+ });
+
+ it("keeps legacy main requester keys for sandboxed session tree checks", async () => {
+ resetSessionStore({
+ "agent:main:main": {
+ sessionId: "s-main",
+ updatedAt: 10,
+ },
+ "agent:main:subagent:child": {
+ sessionId: "s-child",
+ updatedAt: 20,
+ },
+ });
+ mockConfig = {
+ session: { mainKey: "main", scope: "per-sender" },
+ tools: {
+ sessions: { visibility: "all" },
+ agentToAgent: { enabled: true, allow: ["*"] },
+ },
+ agents: {
+ defaults: {
+ model: { primary: "anthropic/claude-opus-4-5" },
+ models: {},
+ sandbox: { sessionToolsVisibility: "spawned" },
+ },
+ },
+ };
+ callGatewayMock.mockImplementation(async (opts: unknown) => {
+ const request = opts as { method?: string; params?: Record };
+ if (request.method === "sessions.list") {
+ return {
+ sessions:
+ request.params?.spawnedBy === "main" ? [{ key: "agent:main:subagent:child" }] : [],
+ };
+ }
+ return {};
+ });
+
+ const tool = getSessionStatusTool("main", {
+ sandboxed: true,
+ });
+
+ const mainResult = await tool.execute("call8", {});
+ const mainDetails = mainResult.details as { ok?: boolean; sessionKey?: string };
+ expect(mainDetails.ok).toBe(true);
+ expect(mainDetails.sessionKey).toBe("agent:main:main");
+
+ const childResult = await tool.execute("call9", {
+ sessionKey: "agent:main:subagent:child",
+ });
+ const childDetails = childResult.details as { ok?: boolean; sessionKey?: string };
+ expect(childDetails.ok).toBe(true);
+ expect(childDetails.sessionKey).toBe("agent:main:subagent:child");
+
+ expect(callGatewayMock).toHaveBeenCalledTimes(2);
+ expect(callGatewayMock).toHaveBeenNthCalledWith(1, {
+ method: "sessions.list",
+ params: {
+ includeGlobal: false,
+ includeUnknown: false,
+ limit: 500,
+ spawnedBy: "main",
+ },
+ });
+ expect(callGatewayMock).toHaveBeenNthCalledWith(2, {
+ method: "sessions.list",
+ params: {
+ includeGlobal: false,
+ includeUnknown: false,
+ limit: 500,
+ spawnedBy: "main",
+ },
+ });
+ });
+
it("scopes bare session keys to the requester agent", async () => {
loadSessionStoreMock.mockClear();
updateSessionStoreMock.mockClear();
diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts
index b9c86bf7472..34fcbfbafd4 100644
--- a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts
+++ b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts
@@ -85,7 +85,10 @@ describe("sessions_spawn depth + child limits", () => {
});
it("rejects spawning when caller depth reaches maxSpawnDepth", async () => {
- const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" });
+ const tool = createSessionsSpawnTool({
+ agentSessionKey: "agent:main:subagent:parent",
+ workspaceDir: "/parent/workspace",
+ });
const result = await tool.execute("call-depth-reject", { task: "hello" });
expect(result.details).toMatchObject({
@@ -109,8 +112,13 @@ describe("sessions_spawn depth + child limits", () => {
const calls = callGatewayMock.mock.calls.map(
(call) => call[0] as { method?: string; params?: Record },
);
- const agentCall = calls.find((entry) => entry.method === "agent");
- expect(agentCall?.params?.spawnedBy).toBe("agent:main:subagent:parent");
+ const spawnedByPatch = calls.find(
+ (entry) =>
+ entry.method === "sessions.patch" &&
+ entry.params?.spawnedBy === "agent:main:subagent:parent",
+ );
+ expect(spawnedByPatch?.params?.key).toMatch(/^agent:main:subagent:/);
+ expect(typeof spawnedByPatch?.params?.spawnedWorkspaceDir).toBe("string");
const spawnDepthPatch = calls.find(
(entry) => entry.method === "sessions.patch" && entry.params?.spawnDepth === 2,
diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts
index 8473e4a06e8..a400ac133cd 100644
--- a/src/agents/openclaw-tools.ts
+++ b/src/agents/openclaw-tools.ts
@@ -200,6 +200,7 @@ export function createOpenClawTools(
createSessionStatusTool({
agentSessionKey: options?.agentSessionKey,
config: options?.config,
+ sandboxed: options?.sandboxed,
}),
...(webSearchTool ? [webSearchTool] : []),
...(webFetchTool ? [webFetchTool] : []),
diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts
index 27c89afe425..9ed183a6910 100644
--- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts
+++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts
@@ -535,6 +535,23 @@ describe("isFailoverErrorMessage", () => {
}
});
+ it("matches network errno codes in serialized error messages", () => {
+ const samples = [
+ "Error: connect ETIMEDOUT 10.0.0.1:443",
+ "Error: connect ESOCKETTIMEDOUT 10.0.0.1:443",
+ "Error: connect EHOSTUNREACH 10.0.0.1:443",
+ "Error: connect ENETUNREACH 10.0.0.1:443",
+ "Error: write EPIPE",
+ "Error: read ENETRESET",
+ "Error: connect EHOSTDOWN 192.168.1.1:443",
+ ];
+ for (const sample of samples) {
+ expect(isTimeoutErrorMessage(sample)).toBe(true);
+ expect(classifyFailoverReason(sample)).toBe("timeout");
+ expect(isFailoverErrorMessage(sample)).toBe(true);
+ }
+ });
+
it("does not classify MALFORMED_FUNCTION_CALL as timeout", () => {
const sample = "Unhandled stop reason: MALFORMED_FUNCTION_CALL";
expect(isTimeoutErrorMessage(sample)).toBe(false);
diff --git a/src/agents/pi-embedded-helpers/failover-matches.ts b/src/agents/pi-embedded-helpers/failover-matches.ts
index a9f16fa6202..ffe0c428f55 100644
--- a/src/agents/pi-embedded-helpers/failover-matches.ts
+++ b/src/agents/pi-embedded-helpers/failover-matches.ts
@@ -37,6 +37,13 @@ const ERROR_PATTERNS = {
"fetch failed",
"socket hang up",
/\beconn(?:refused|reset|aborted)\b/i,
+ /\benetunreach\b/i,
+ /\behostunreach\b/i,
+ /\behostdown\b/i,
+ /\benetreset\b/i,
+ /\betimedout\b/i,
+ /\besockettimedout\b/i,
+ /\bepipe\b/i,
/\benotfound\b/i,
/\beai_again\b/i,
/without sending (?:any )?chunks?/i,
diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts
index 9821adc0e0b..0203721224f 100644
--- a/src/agents/pi-embedded-runner/run/attempt.test.ts
+++ b/src/agents/pi-embedded-runner/run/attempt.test.ts
@@ -13,6 +13,7 @@ import {
shouldInjectOllamaCompatNumCtx,
decodeHtmlEntitiesInObject,
wrapOllamaCompatNumCtx,
+ wrapStreamFnRepairMalformedToolCallArguments,
wrapStreamFnTrimToolCallNames,
} from "./attempt.js";
@@ -430,6 +431,182 @@ describe("wrapStreamFnTrimToolCallNames", () => {
});
});
+describe("wrapStreamFnRepairMalformedToolCallArguments", () => {
+ function createFakeStream(params: { events: unknown[]; resultMessage: unknown }): {
+ result: () => Promise;
+ [Symbol.asyncIterator]: () => AsyncIterator;
+ } {
+ return {
+ async result() {
+ return params.resultMessage;
+ },
+ [Symbol.asyncIterator]() {
+ return (async function* () {
+ for (const event of params.events) {
+ yield event;
+ }
+ })();
+ },
+ };
+ }
+
+ async function invokeWrappedStream(baseFn: (...args: never[]) => unknown) {
+ const wrappedFn = wrapStreamFnRepairMalformedToolCallArguments(baseFn as never);
+ return await wrappedFn({} as never, {} as never, {} as never);
+ }
+
+ it("repairs anthropic-compatible tool arguments when trailing junk follows valid JSON", async () => {
+ const partialToolCall = { type: "toolCall", name: "read", arguments: {} };
+ const streamedToolCall = { type: "toolCall", name: "read", arguments: {} };
+ const endMessageToolCall = { type: "toolCall", name: "read", arguments: {} };
+ const finalToolCall = { type: "toolCall", name: "read", arguments: {} };
+ const partialMessage = { role: "assistant", content: [partialToolCall] };
+ const endMessage = { role: "assistant", content: [endMessageToolCall] };
+ const finalMessage = { role: "assistant", content: [finalToolCall] };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [
+ {
+ type: "toolcall_delta",
+ contentIndex: 0,
+ delta: '{"path":"/tmp/report.txt"}',
+ partial: partialMessage,
+ },
+ {
+ type: "toolcall_delta",
+ contentIndex: 0,
+ delta: "xx",
+ partial: partialMessage,
+ },
+ {
+ type: "toolcall_end",
+ contentIndex: 0,
+ toolCall: streamedToolCall,
+ partial: partialMessage,
+ message: endMessage,
+ },
+ ],
+ resultMessage: finalMessage,
+ }),
+ );
+
+ const stream = await invokeWrappedStream(baseFn);
+ for await (const _item of stream) {
+ // drain
+ }
+ const result = await stream.result();
+
+ expect(partialToolCall.arguments).toEqual({ path: "/tmp/report.txt" });
+ expect(streamedToolCall.arguments).toEqual({ path: "/tmp/report.txt" });
+ expect(endMessageToolCall.arguments).toEqual({ path: "/tmp/report.txt" });
+ expect(finalToolCall.arguments).toEqual({ path: "/tmp/report.txt" });
+ expect(result).toBe(finalMessage);
+ });
+
+ it("keeps incomplete partial JSON unchanged until a complete object exists", async () => {
+ const partialToolCall = { type: "toolCall", name: "read", arguments: {} };
+ const partialMessage = { role: "assistant", content: [partialToolCall] };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [
+ {
+ type: "toolcall_delta",
+ contentIndex: 0,
+ delta: '{"path":"/tmp',
+ partial: partialMessage,
+ },
+ ],
+ resultMessage: { role: "assistant", content: [partialToolCall] },
+ }),
+ );
+
+ const stream = await invokeWrappedStream(baseFn);
+ for await (const _item of stream) {
+ // drain
+ }
+
+ expect(partialToolCall.arguments).toEqual({});
+ });
+
+ it("does not repair tool arguments when trailing junk exceeds the Kimi-specific allowance", async () => {
+ const partialToolCall = { type: "toolCall", name: "read", arguments: {} };
+ const streamedToolCall = { type: "toolCall", name: "read", arguments: {} };
+ const partialMessage = { role: "assistant", content: [partialToolCall] };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [
+ {
+ type: "toolcall_delta",
+ contentIndex: 0,
+ delta: '{"path":"/tmp/report.txt"}oops',
+ partial: partialMessage,
+ },
+ {
+ type: "toolcall_end",
+ contentIndex: 0,
+ toolCall: streamedToolCall,
+ partial: partialMessage,
+ },
+ ],
+ resultMessage: { role: "assistant", content: [partialToolCall] },
+ }),
+ );
+
+ const stream = await invokeWrappedStream(baseFn);
+ for await (const _item of stream) {
+ // drain
+ }
+
+ expect(partialToolCall.arguments).toEqual({});
+ expect(streamedToolCall.arguments).toEqual({});
+ });
+
+ it("clears a cached repair when later deltas make the trailing suffix invalid", async () => {
+ const partialToolCall = { type: "toolCall", name: "read", arguments: {} };
+ const streamedToolCall = { type: "toolCall", name: "read", arguments: {} };
+ const partialMessage = { role: "assistant", content: [partialToolCall] };
+ const baseFn = vi.fn(() =>
+ createFakeStream({
+ events: [
+ {
+ type: "toolcall_delta",
+ contentIndex: 0,
+ delta: '{"path":"/tmp/report.txt"}',
+ partial: partialMessage,
+ },
+ {
+ type: "toolcall_delta",
+ contentIndex: 0,
+ delta: "x",
+ partial: partialMessage,
+ },
+ {
+ type: "toolcall_delta",
+ contentIndex: 0,
+ delta: "yzq",
+ partial: partialMessage,
+ },
+ {
+ type: "toolcall_end",
+ contentIndex: 0,
+ toolCall: streamedToolCall,
+ partial: partialMessage,
+ },
+ ],
+ resultMessage: { role: "assistant", content: [partialToolCall] },
+ }),
+ );
+
+ const stream = await invokeWrappedStream(baseFn);
+ for await (const _item of stream) {
+ // drain
+ }
+
+ expect(partialToolCall.arguments).toEqual({});
+ expect(streamedToolCall.arguments).toEqual({});
+ });
+});
+
describe("isOllamaCompatProvider", () => {
it("detects native ollama provider id", () => {
expect(
diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts
index 2f5f3d04d5f..2f77b46aff5 100644
--- a/src/agents/pi-embedded-runner/run/attempt.ts
+++ b/src/agents/pi-embedded-runner/run/attempt.ts
@@ -436,6 +436,281 @@ export function wrapStreamFnTrimToolCallNames(
};
}
+function extractBalancedJsonPrefix(raw: string): string | null {
+ let start = 0;
+ while (start < raw.length && /\s/.test(raw[start] ?? "")) {
+ start += 1;
+ }
+ const startChar = raw[start];
+ if (startChar !== "{" && startChar !== "[") {
+ return null;
+ }
+
+ let depth = 0;
+ let inString = false;
+ let escaped = false;
+ for (let i = start; i < raw.length; i += 1) {
+ const char = raw[i];
+ if (char === undefined) {
+ break;
+ }
+ if (inString) {
+ if (escaped) {
+ escaped = false;
+ } else if (char === "\\") {
+ escaped = true;
+ } else if (char === '"') {
+ inString = false;
+ }
+ continue;
+ }
+ if (char === '"') {
+ inString = true;
+ continue;
+ }
+ if (char === "{" || char === "[") {
+ depth += 1;
+ continue;
+ }
+ if (char === "}" || char === "]") {
+ depth -= 1;
+ if (depth === 0) {
+ return raw.slice(start, i + 1);
+ }
+ }
+ }
+ return null;
+}
+
+const MAX_TOOLCALL_REPAIR_BUFFER_CHARS = 64_000;
+const MAX_TOOLCALL_REPAIR_TRAILING_CHARS = 3;
+const TOOLCALL_REPAIR_ALLOWED_TRAILING_RE = /^[^\s{}[\]":,\\]{1,3}$/;
+
+function shouldAttemptMalformedToolCallRepair(partialJson: string, delta: string): boolean {
+ if (/[}\]]/.test(delta)) {
+ return true;
+ }
+ const trimmedDelta = delta.trim();
+ return (
+ trimmedDelta.length > 0 &&
+ trimmedDelta.length <= MAX_TOOLCALL_REPAIR_TRAILING_CHARS &&
+ /[}\]]/.test(partialJson)
+ );
+}
+
+type ToolCallArgumentRepair = {
+ args: Record;
+ trailingSuffix: string;
+};
+
+function tryParseMalformedToolCallArguments(raw: string): ToolCallArgumentRepair | undefined {
+ if (!raw.trim()) {
+ return undefined;
+ }
+ try {
+ JSON.parse(raw);
+ return undefined;
+ } catch {
+ const jsonPrefix = extractBalancedJsonPrefix(raw);
+ if (!jsonPrefix) {
+ return undefined;
+ }
+ const suffix = raw.slice(raw.indexOf(jsonPrefix) + jsonPrefix.length).trim();
+ if (
+ suffix.length === 0 ||
+ suffix.length > MAX_TOOLCALL_REPAIR_TRAILING_CHARS ||
+ !TOOLCALL_REPAIR_ALLOWED_TRAILING_RE.test(suffix)
+ ) {
+ return undefined;
+ }
+ try {
+ const parsed = JSON.parse(jsonPrefix) as unknown;
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
+ ? { args: parsed as Record, trailingSuffix: suffix }
+ : undefined;
+ } catch {
+ return undefined;
+ }
+ }
+}
+
+function repairToolCallArgumentsInMessage(
+ message: unknown,
+ contentIndex: number,
+ repairedArgs: Record,
+): void {
+ if (!message || typeof message !== "object") {
+ return;
+ }
+ const content = (message as { content?: unknown }).content;
+ if (!Array.isArray(content)) {
+ return;
+ }
+ const block = content[contentIndex];
+ if (!block || typeof block !== "object") {
+ return;
+ }
+ const typedBlock = block as { type?: unknown; arguments?: unknown };
+ if (!isToolCallBlockType(typedBlock.type)) {
+ return;
+ }
+ typedBlock.arguments = repairedArgs;
+}
+
+function clearToolCallArgumentsInMessage(message: unknown, contentIndex: number): void {
+ if (!message || typeof message !== "object") {
+ return;
+ }
+ const content = (message as { content?: unknown }).content;
+ if (!Array.isArray(content)) {
+ return;
+ }
+ const block = content[contentIndex];
+ if (!block || typeof block !== "object") {
+ return;
+ }
+ const typedBlock = block as { type?: unknown; arguments?: unknown };
+ if (!isToolCallBlockType(typedBlock.type)) {
+ return;
+ }
+ typedBlock.arguments = {};
+}
+
+function repairMalformedToolCallArgumentsInMessage(
+ message: unknown,
+ repairedArgsByIndex: Map>,
+): void {
+ if (!message || typeof message !== "object") {
+ return;
+ }
+ const content = (message as { content?: unknown }).content;
+ if (!Array.isArray(content)) {
+ return;
+ }
+ for (const [index, repairedArgs] of repairedArgsByIndex.entries()) {
+ repairToolCallArgumentsInMessage(message, index, repairedArgs);
+ }
+}
+
+function wrapStreamRepairMalformedToolCallArguments(
+ stream: ReturnType,
+): ReturnType {
+ const partialJsonByIndex = new Map();
+ const repairedArgsByIndex = new Map>();
+ const disabledIndices = new Set();
+ const loggedRepairIndices = new Set();
+ const originalResult = stream.result.bind(stream);
+ stream.result = async () => {
+ const message = await originalResult();
+ repairMalformedToolCallArgumentsInMessage(message, repairedArgsByIndex);
+ partialJsonByIndex.clear();
+ repairedArgsByIndex.clear();
+ disabledIndices.clear();
+ loggedRepairIndices.clear();
+ return message;
+ };
+
+ const originalAsyncIterator = stream[Symbol.asyncIterator].bind(stream);
+ (stream as { [Symbol.asyncIterator]: typeof originalAsyncIterator })[Symbol.asyncIterator] =
+ function () {
+ const iterator = originalAsyncIterator();
+ return {
+ async next() {
+ const result = await iterator.next();
+ if (!result.done && result.value && typeof result.value === "object") {
+ const event = result.value as {
+ type?: unknown;
+ contentIndex?: unknown;
+ delta?: unknown;
+ partial?: unknown;
+ message?: unknown;
+ toolCall?: unknown;
+ };
+ if (
+ typeof event.contentIndex === "number" &&
+ Number.isInteger(event.contentIndex) &&
+ event.type === "toolcall_delta" &&
+ typeof event.delta === "string"
+ ) {
+ if (disabledIndices.has(event.contentIndex)) {
+ return result;
+ }
+ const nextPartialJson =
+ (partialJsonByIndex.get(event.contentIndex) ?? "") + event.delta;
+ if (nextPartialJson.length > MAX_TOOLCALL_REPAIR_BUFFER_CHARS) {
+ partialJsonByIndex.delete(event.contentIndex);
+ repairedArgsByIndex.delete(event.contentIndex);
+ disabledIndices.add(event.contentIndex);
+ return result;
+ }
+ partialJsonByIndex.set(event.contentIndex, nextPartialJson);
+ if (shouldAttemptMalformedToolCallRepair(nextPartialJson, event.delta)) {
+ const repair = tryParseMalformedToolCallArguments(nextPartialJson);
+ if (repair) {
+ repairedArgsByIndex.set(event.contentIndex, repair.args);
+ repairToolCallArgumentsInMessage(event.partial, event.contentIndex, repair.args);
+ repairToolCallArgumentsInMessage(event.message, event.contentIndex, repair.args);
+ if (!loggedRepairIndices.has(event.contentIndex)) {
+ loggedRepairIndices.add(event.contentIndex);
+ log.warn(
+ `repairing kimi-coding tool call arguments after ${repair.trailingSuffix.length} trailing chars`,
+ );
+ }
+ } else {
+ repairedArgsByIndex.delete(event.contentIndex);
+ clearToolCallArgumentsInMessage(event.partial, event.contentIndex);
+ clearToolCallArgumentsInMessage(event.message, event.contentIndex);
+ }
+ }
+ }
+ if (
+ typeof event.contentIndex === "number" &&
+ Number.isInteger(event.contentIndex) &&
+ event.type === "toolcall_end"
+ ) {
+ const repairedArgs = repairedArgsByIndex.get(event.contentIndex);
+ if (repairedArgs) {
+ if (event.toolCall && typeof event.toolCall === "object") {
+ (event.toolCall as { arguments?: unknown }).arguments = repairedArgs;
+ }
+ repairToolCallArgumentsInMessage(event.partial, event.contentIndex, repairedArgs);
+ repairToolCallArgumentsInMessage(event.message, event.contentIndex, repairedArgs);
+ }
+ partialJsonByIndex.delete(event.contentIndex);
+ disabledIndices.delete(event.contentIndex);
+ loggedRepairIndices.delete(event.contentIndex);
+ }
+ }
+ return result;
+ },
+ async return(value?: unknown) {
+ return iterator.return?.(value) ?? { done: true as const, value: undefined };
+ },
+ async throw(error?: unknown) {
+ return iterator.throw?.(error) ?? { done: true as const, value: undefined };
+ },
+ };
+ };
+
+ return stream;
+}
+
+export function wrapStreamFnRepairMalformedToolCallArguments(baseFn: StreamFn): StreamFn {
+ return (model, context, options) => {
+ const maybeStream = baseFn(model, context, options);
+ if (maybeStream && typeof maybeStream === "object" && "then" in maybeStream) {
+ return Promise.resolve(maybeStream).then((stream) =>
+ wrapStreamRepairMalformedToolCallArguments(stream),
+ );
+ }
+ return wrapStreamRepairMalformedToolCallArguments(maybeStream);
+ };
+}
+
+function shouldRepairMalformedAnthropicToolCallArguments(provider?: string): boolean {
+ return normalizeProviderId(provider ?? "") === "kimi-coding";
+}
+
// ---------------------------------------------------------------------------
// xAI / Grok: decode HTML entities in tool call arguments
// ---------------------------------------------------------------------------
@@ -1379,6 +1654,15 @@ export async function runEmbeddedAttempt(
allowedToolNames,
);
+ if (
+ params.model.api === "anthropic-messages" &&
+ shouldRepairMalformedAnthropicToolCallArguments(params.provider)
+ ) {
+ activeSession.agent.streamFn = wrapStreamFnRepairMalformedToolCallArguments(
+ activeSession.agent.streamFn,
+ );
+ }
+
if (isXaiProvider(params.provider, params.modelId)) {
activeSession.agent.streamFn = wrapStreamFnDecodeXaiToolCallArguments(
activeSession.agent.streamFn,
diff --git a/src/agents/sandbox/fs-bridge-mutation-helper.test.ts b/src/agents/sandbox/fs-bridge-mutation-helper.test.ts
index f2d3974f0cc..57f22cc84b6 100644
--- a/src/agents/sandbox/fs-bridge-mutation-helper.test.ts
+++ b/src/agents/sandbox/fs-bridge-mutation-helper.test.ts
@@ -3,7 +3,10 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
-import { SANDBOX_PINNED_MUTATION_PYTHON } from "./fs-bridge-mutation-helper.js";
+import {
+ buildPinnedWritePlan,
+ SANDBOX_PINNED_MUTATION_PYTHON,
+} from "./fs-bridge-mutation-helper.js";
async function withTempRoot(prefix: string, run: (root: string) => Promise): Promise {
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
@@ -22,6 +25,35 @@ function runMutation(args: string[], input?: string) {
});
}
+function runWritePlan(args: string[], input?: string) {
+ const plan = buildPinnedWritePlan({
+ check: {
+ target: {
+ hostPath: args[1] ?? "",
+ containerPath: args[1] ?? "",
+ relativePath: path.posix.join(args[2] ?? "", args[3] ?? ""),
+ writable: true,
+ },
+ options: {
+ action: "write files",
+ requireWritable: true,
+ },
+ },
+ pinned: {
+ mountRootPath: args[1] ?? "",
+ relativeParentPath: args[2] ?? "",
+ basename: args[3] ?? "",
+ },
+ mkdir: args[4] === "1",
+ });
+
+ return spawnSync("sh", ["-c", plan.script, "moltbot-sandbox-fs", ...(plan.args ?? [])], {
+ input,
+ encoding: "utf8",
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+}
+
describe("sandbox pinned mutation helper", () => {
it("writes through a pinned directory fd", async () => {
await withTempRoot("openclaw-mutation-helper-", async (root) => {
@@ -37,6 +69,26 @@ describe("sandbox pinned mutation helper", () => {
});
});
+ it.runIf(process.platform !== "win32")(
+ "preserves stdin payload bytes when the pinned write plan runs through sh",
+ async () => {
+ await withTempRoot("openclaw-mutation-helper-", async (root) => {
+ const workspace = path.join(root, "workspace");
+ await fs.mkdir(workspace, { recursive: true });
+
+ const result = runWritePlan(
+ ["write", workspace, "nested/deeper", "note.txt", "1"],
+ "hello",
+ );
+
+ expect(result.status).toBe(0);
+ await expect(
+ fs.readFile(path.join(workspace, "nested", "deeper", "note.txt"), "utf8"),
+ ).resolves.toBe("hello");
+ });
+ },
+ );
+
it.runIf(process.platform !== "win32")(
"rejects symlink-parent writes instead of materializing a temp file outside the mount",
async () => {
diff --git a/src/agents/sandbox/fs-bridge-mutation-helper.ts b/src/agents/sandbox/fs-bridge-mutation-helper.ts
index fc50c5ab756..3c6edb2c2cb 100644
--- a/src/agents/sandbox/fs-bridge-mutation-helper.ts
+++ b/src/agents/sandbox/fs-bridge-mutation-helper.ts
@@ -257,7 +257,13 @@ function buildPinnedMutationPlan(params: {
return {
checks: params.checks,
recheckBeforeCommand: true,
- script: ["set -eu", "python3 - \"$@\" <<'PY'", SANDBOX_PINNED_MUTATION_PYTHON, "PY"].join("\n"),
+ // Feed the helper source over fd 3 so stdin stays available for write payload bytes.
+ script: [
+ "set -eu",
+ "python3 /dev/fd/3 \"$@\" 3<<'PY'",
+ SANDBOX_PINNED_MUTATION_PYTHON,
+ "PY",
+ ].join("\n"),
args: params.args,
};
}
diff --git a/src/agents/sandbox/fs-bridge.anchored-ops.test.ts b/src/agents/sandbox/fs-bridge.anchored-ops.test.ts
index a2a17194347..48e7e9e23f8 100644
--- a/src/agents/sandbox/fs-bridge.anchored-ops.test.ts
+++ b/src/agents/sandbox/fs-bridge.anchored-ops.test.ts
@@ -120,7 +120,7 @@ describe("sandbox fs bridge anchored ops", () => {
const opCall = mockedExecDockerRaw.mock.calls.find(
([args]) =>
typeof args[5] === "string" &&
- args[5].includes("python3 - \"$@\" <<'PY'") &&
+ args[5].includes("python3 /dev/fd/3 \"$@\" 3<<'PY'") &&
getDockerArg(args, 1) === testCase.expectedArgs[0],
);
expect(opCall).toBeDefined();
diff --git a/src/agents/sandbox/fs-bridge.shell.test.ts b/src/agents/sandbox/fs-bridge.shell.test.ts
index 24b7d9faba4..1685759ad38 100644
--- a/src/agents/sandbox/fs-bridge.shell.test.ts
+++ b/src/agents/sandbox/fs-bridge.shell.test.ts
@@ -129,6 +129,10 @@ describe("sandbox fs bridge shell compatibility", () => {
await bridge.writeFile({ filePath: "b.txt", data: "hello" });
const scripts = getScriptsFromCalls();
+ expect(scripts.some((script) => script.includes("python3 - \"$@\" <<'PY'"))).toBe(false);
+ expect(scripts.some((script) => script.includes("python3 /dev/fd/3 \"$@\" 3<<'PY'"))).toBe(
+ true,
+ );
expect(scripts.some((script) => script.includes('cat >"$1"'))).toBe(false);
expect(scripts.some((script) => script.includes('cat >"$tmp"'))).toBe(false);
expect(scripts.some((script) => script.includes("os.replace("))).toBe(true);
diff --git a/src/agents/sessions-spawn-hooks.test.ts b/src/agents/sessions-spawn-hooks.test.ts
index e7abc2dba9f..89004289369 100644
--- a/src/agents/sessions-spawn-hooks.test.ts
+++ b/src/agents/sessions-spawn-hooks.test.ts
@@ -380,4 +380,36 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
emitLifecycleHooks: true,
});
});
+
+ it("cleans up the provisional session when lineage patching fails after thread binding", async () => {
+ const callGatewayMock = getCallGatewayMock();
+ callGatewayMock.mockImplementation(async (opts: unknown) => {
+ const request = opts as { method?: string; params?: Record };
+ if (request.method === "sessions.patch" && typeof request.params?.spawnedBy === "string") {
+ throw new Error("lineage patch failed");
+ }
+ if (request.method === "sessions.delete") {
+ return { ok: true };
+ }
+ return {};
+ });
+
+ const result = await executeDiscordThreadSessionSpawn("call9");
+
+ expect(result.details).toMatchObject({
+ status: "error",
+ error: "lineage patch failed",
+ });
+ expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
+ expect(hookRunnerMocks.runSubagentEnded).not.toHaveBeenCalled();
+ const methods = getGatewayMethods();
+ expect(methods).toContain("sessions.delete");
+ expect(methods).not.toContain("agent");
+ const deleteCall = findGatewayRequest("sessions.delete");
+ expect(deleteCall?.params).toMatchObject({
+ key: (result.details as { childSessionKey?: string }).childSessionKey,
+ deleteTranscript: true,
+ emitLifecycleHooks: true,
+ });
+ });
});
diff --git a/src/agents/subagent-spawn.attachments.test.ts b/src/agents/subagent-spawn.attachments.test.ts
index b564e77a906..9fe774fa284 100644
--- a/src/agents/subagent-spawn.attachments.test.ts
+++ b/src/agents/subagent-spawn.attachments.test.ts
@@ -1,6 +1,7 @@
+import fs from "node:fs";
import os from "node:os";
import path from "node:path";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
import { decodeStrictBase64, spawnSubagentDirect } from "./subagent-spawn.js";
@@ -31,6 +32,7 @@ let configOverride: Record = {
},
},
};
+let workspaceDirOverride = "";
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal();
@@ -61,7 +63,7 @@ vi.mock("./agent-scope.js", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
- resolveAgentWorkspaceDir: () => path.join(os.tmpdir(), "agent-workspace"),
+ resolveAgentWorkspaceDir: () => workspaceDirOverride,
};
});
@@ -145,6 +147,16 @@ describe("spawnSubagentDirect filename validation", () => {
resetSubagentRegistryForTests();
callGatewayMock.mockClear();
setupGatewayMock();
+ workspaceDirOverride = fs.mkdtempSync(
+ path.join(os.tmpdir(), `openclaw-subagent-attachments-${process.pid}-${Date.now()}-`),
+ );
+ });
+
+ afterEach(() => {
+ if (workspaceDirOverride) {
+ fs.rmSync(workspaceDirOverride, { recursive: true, force: true });
+ workspaceDirOverride = "";
+ }
});
const ctx = {
@@ -210,4 +222,43 @@ describe("spawnSubagentDirect filename validation", () => {
expect(result.status).toBe("error");
expect(result.error).toMatch(/attachments_invalid_name/);
});
+
+ it("removes materialized attachments when lineage patching fails", async () => {
+ const calls: Array<{ method?: string; params?: Record }> = [];
+ callGatewayMock.mockImplementation(async (opts: unknown) => {
+ const request = opts as { method?: string; params?: Record };
+ calls.push(request);
+ if (request.method === "sessions.patch" && typeof request.params?.spawnedBy === "string") {
+ throw new Error("lineage patch failed");
+ }
+ if (request.method === "sessions.delete") {
+ return { ok: true };
+ }
+ return {};
+ });
+
+ const result = await spawnSubagentDirect(
+ {
+ task: "test",
+ attachments: [{ name: "file.txt", content: validContent, encoding: "base64" }],
+ },
+ ctx,
+ );
+
+ expect(result).toMatchObject({
+ status: "error",
+ error: "lineage patch failed",
+ });
+ const attachmentsRoot = path.join(workspaceDirOverride, ".openclaw", "attachments");
+ const retainedDirs = fs.existsSync(attachmentsRoot)
+ ? fs.readdirSync(attachmentsRoot).filter((entry) => !entry.startsWith("."))
+ : [];
+ expect(retainedDirs).toHaveLength(0);
+ const deleteCall = calls.find((entry) => entry.method === "sessions.delete");
+ expect(deleteCall?.params).toMatchObject({
+ key: expect.stringMatching(/^agent:main:subagent:/),
+ deleteTranscript: true,
+ emitLifecycleHooks: false,
+ });
+ });
});
diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts
index be5dac37f83..a4a6229c715 100644
--- a/src/agents/subagent-spawn.ts
+++ b/src/agents/subagent-spawn.ts
@@ -153,6 +153,25 @@ async function cleanupProvisionalSession(
}
}
+async function cleanupFailedSpawnBeforeAgentStart(params: {
+ childSessionKey: string;
+ attachmentAbsDir?: string;
+ emitLifecycleHooks?: boolean;
+ deleteTranscript?: boolean;
+}): Promise {
+ if (params.attachmentAbsDir) {
+ try {
+ await fs.rm(params.attachmentAbsDir, { recursive: true, force: true });
+ } catch {
+ // Best-effort cleanup only.
+ }
+ }
+ await cleanupProvisionalSession(params.childSessionKey, {
+ emitLifecycleHooks: params.emitLifecycleHooks,
+ deleteTranscript: params.deleteTranscript,
+ });
+}
+
function resolveSpawnMode(params: {
requestedMode?: SpawnSubagentMode;
threadRequested: boolean;
@@ -561,10 +580,32 @@ export async function spawnSubagentDirect(
explicitWorkspaceDir: toolSpawnMetadata.workspaceDir,
}),
});
+ const spawnLineagePatchError = await patchChildSession({
+ spawnedBy: spawnedByKey,
+ ...(spawnedMetadata.workspaceDir ? { spawnedWorkspaceDir: spawnedMetadata.workspaceDir } : {}),
+ });
+ if (spawnLineagePatchError) {
+ await cleanupFailedSpawnBeforeAgentStart({
+ childSessionKey,
+ attachmentAbsDir,
+ emitLifecycleHooks: threadBindingReady,
+ deleteTranscript: true,
+ });
+ return {
+ status: "error",
+ error: spawnLineagePatchError,
+ childSessionKey,
+ };
+ }
const childIdem = crypto.randomUUID();
let childRunId: string = childIdem;
try {
+ const {
+ spawnedBy: _spawnedBy,
+ workspaceDir: _workspaceDir,
+ ...publicSpawnedMetadata
+ } = spawnedMetadata;
const response = await callGateway<{ runId: string }>({
method: "agent",
params: {
@@ -581,7 +622,7 @@ export async function spawnSubagentDirect(
thinking: thinkingOverride,
timeout: runTimeoutSeconds,
label: label || undefined,
- ...spawnedMetadata,
+ ...publicSpawnedMetadata,
},
timeoutMs: 10_000,
});
diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts
index 2277b6e8ad2..29d8204b750 100644
--- a/src/agents/tools/session-status-tool.ts
+++ b/src/agents/tools/session-status-tool.ts
@@ -19,6 +19,7 @@ import {
import {
buildAgentMainSessionKey,
DEFAULT_AGENT_ID,
+ parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "../../routing/session-key.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
@@ -36,10 +37,12 @@ import {
import type { AnyAgentTool } from "./common.js";
import { readStringParam } from "./common.js";
import {
+ createSessionVisibilityGuard,
shouldResolveSessionIdInput,
- resolveInternalSessionKey,
- resolveMainSessionAlias,
createAgentToAgentPolicy,
+ resolveEffectiveSessionToolsVisibility,
+ resolveInternalSessionKey,
+ resolveSandboxedSessionToolContext,
} from "./sessions-helpers.js";
const SessionStatusToolSchema = Type.Object({
@@ -175,6 +178,7 @@ async function resolveModelOverride(params: {
export function createSessionStatusTool(opts?: {
agentSessionKey?: string;
config?: OpenClawConfig;
+ sandboxed?: boolean;
}): AnyAgentTool {
return {
label: "Session Status",
@@ -185,18 +189,70 @@ export function createSessionStatusTool(opts?: {
execute: async (_toolCallId, args) => {
const params = args as Record;
const cfg = opts?.config ?? loadConfig();
- const { mainKey, alias } = resolveMainSessionAlias(cfg);
+ const { mainKey, alias, effectiveRequesterKey } = resolveSandboxedSessionToolContext({
+ cfg,
+ agentSessionKey: opts?.agentSessionKey,
+ sandboxed: opts?.sandboxed,
+ });
const a2aPolicy = createAgentToAgentPolicy(cfg);
+ const requesterAgentId = resolveAgentIdFromSessionKey(
+ opts?.agentSessionKey ?? effectiveRequesterKey,
+ );
+ const visibilityRequesterKey = effectiveRequesterKey.trim();
+ const usesLegacyMainAlias = alias === mainKey;
+ const isLegacyMainVisibilityKey = (sessionKey: string) => {
+ const trimmed = sessionKey.trim();
+ return usesLegacyMainAlias && (trimmed === "main" || trimmed === mainKey);
+ };
+ const resolveVisibilityMainSessionKey = (sessionAgentId: string) => {
+ const requesterParsed = parseAgentSessionKey(visibilityRequesterKey);
+ if (
+ resolveAgentIdFromSessionKey(visibilityRequesterKey) === sessionAgentId &&
+ (requesterParsed?.rest === mainKey || isLegacyMainVisibilityKey(visibilityRequesterKey))
+ ) {
+ return visibilityRequesterKey;
+ }
+ return buildAgentMainSessionKey({
+ agentId: sessionAgentId,
+ mainKey,
+ });
+ };
+ const normalizeVisibilityTargetSessionKey = (sessionKey: string, sessionAgentId: string) => {
+ const trimmed = sessionKey.trim();
+ if (!trimmed) {
+ return trimmed;
+ }
+ if (trimmed.startsWith("agent:")) {
+ const parsed = parseAgentSessionKey(trimmed);
+ if (parsed?.rest === mainKey) {
+ return resolveVisibilityMainSessionKey(sessionAgentId);
+ }
+ return trimmed;
+ }
+ // Preserve legacy bare main keys for requester tree checks.
+ if (isLegacyMainVisibilityKey(trimmed)) {
+ return resolveVisibilityMainSessionKey(sessionAgentId);
+ }
+ return trimmed;
+ };
+ const visibilityGuard =
+ opts?.sandboxed === true
+ ? await createSessionVisibilityGuard({
+ action: "status",
+ requesterSessionKey: visibilityRequesterKey,
+ visibility: resolveEffectiveSessionToolsVisibility({
+ cfg,
+ sandboxed: true,
+ }),
+ a2aPolicy,
+ })
+ : null;
const requestedKeyParam = readStringParam(params, "sessionKey");
let requestedKeyRaw = requestedKeyParam ?? opts?.agentSessionKey;
if (!requestedKeyRaw?.trim()) {
throw new Error("sessionKey required");
}
-
- const requesterAgentId = resolveAgentIdFromSessionKey(
- opts?.agentSessionKey ?? requestedKeyRaw,
- );
const ensureAgentAccess = (targetAgentId: string) => {
if (targetAgentId === requesterAgentId) {
return;
@@ -213,7 +269,14 @@ export function createSessionStatusTool(opts?: {
};
if (requestedKeyRaw.startsWith("agent:")) {
- ensureAgentAccess(resolveAgentIdFromSessionKey(requestedKeyRaw));
+ const requestedAgentId = resolveAgentIdFromSessionKey(requestedKeyRaw);
+ ensureAgentAccess(requestedAgentId);
+ const access = visibilityGuard?.check(
+ normalizeVisibilityTargetSessionKey(requestedKeyRaw, requestedAgentId),
+ );
+ if (access && !access.allowed) {
+ throw new Error(access.error);
+ }
}
const isExplicitAgentKey = requestedKeyRaw.startsWith("agent:");
@@ -258,6 +321,15 @@ export function createSessionStatusTool(opts?: {
throw new Error(`Unknown ${kind}: ${requestedKeyRaw}`);
}
+ if (visibilityGuard && !requestedKeyRaw.startsWith("agent:")) {
+ const access = visibilityGuard.check(
+ normalizeVisibilityTargetSessionKey(resolved.key, agentId),
+ );
+ if (!access.allowed) {
+ throw new Error(access.error);
+ }
+ }
+
const configured = resolveDefaultModelForAgent({ cfg, agentId });
const modelRaw = readStringParam(params, "model");
let changedModel = false;
diff --git a/src/agents/tools/sessions-access.ts b/src/agents/tools/sessions-access.ts
index 6574c2296cf..47bd0806f7b 100644
--- a/src/agents/tools/sessions-access.ts
+++ b/src/agents/tools/sessions-access.ts
@@ -14,7 +14,7 @@ export type AgentToAgentPolicy = {
isAllowed: (requesterAgentId: string, targetAgentId: string) => boolean;
};
-export type SessionAccessAction = "history" | "send" | "list";
+export type SessionAccessAction = "history" | "send" | "list" | "status";
export type SessionAccessResult =
| { allowed: true }
@@ -130,6 +130,9 @@ function actionPrefix(action: SessionAccessAction): string {
if (action === "send") {
return "Session send";
}
+ if (action === "status") {
+ return "Session status";
+ }
return "Session list";
}
@@ -140,6 +143,9 @@ function a2aDisabledMessage(action: SessionAccessAction): string {
if (action === "send") {
return "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.";
}
+ if (action === "status") {
+ return "Agent-to-agent status is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.";
+ }
return "Agent-to-agent listing is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent visibility.";
}
@@ -150,6 +156,9 @@ function a2aDeniedMessage(action: SessionAccessAction): string {
if (action === "send") {
return "Agent-to-agent messaging denied by tools.agentToAgent.allow.";
}
+ if (action === "status") {
+ return "Agent-to-agent status denied by tools.agentToAgent.allow.";
+ }
return "Agent-to-agent listing denied by tools.agentToAgent.allow.";
}
@@ -160,6 +169,9 @@ function crossVisibilityMessage(action: SessionAccessAction): string {
if (action === "send") {
return "Session send visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access.";
}
+ if (action === "status") {
+ return "Session status visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access.";
+ }
return "Session list visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access.";
}
diff --git a/src/auto-reply/reply/agent-runner-payloads.test.ts b/src/auto-reply/reply/agent-runner-payloads.test.ts
index 94088b2b5b8..26f23d7a42c 100644
--- a/src/auto-reply/reply/agent-runner-payloads.test.ts
+++ b/src/auto-reply/reply/agent-runner-payloads.test.ts
@@ -169,6 +169,50 @@ describe("buildReplyPayloads media filter integration", () => {
expect(replyPayloads).toHaveLength(0);
});
+ it("drops all final payloads when block pipeline streamed successfully", async () => {
+ const pipeline: Parameters[0]["blockReplyPipeline"] = {
+ didStream: () => true,
+ isAborted: () => false,
+ hasSentPayload: () => false,
+ enqueue: () => {},
+ flush: async () => {},
+ stop: () => {},
+ hasBuffered: () => false,
+ };
+ // shouldDropFinalPayloads short-circuits to [] when the pipeline streamed
+ // without aborting, so hasSentPayload is never reached.
+ const { replyPayloads } = await buildReplyPayloads({
+ ...baseParams,
+ blockStreamingEnabled: true,
+ blockReplyPipeline: pipeline,
+ replyToMode: "all",
+ payloads: [{ text: "response", replyToId: "post-123" }],
+ });
+
+ expect(replyPayloads).toHaveLength(0);
+ });
+
+ it("deduplicates final payloads against directly sent block keys regardless of replyToId", async () => {
+ // When block streaming is not active but directlySentBlockKeys has entries
+ // (e.g. from pre-tool flush), the key should match even if replyToId differs.
+ const { createBlockReplyContentKey } = await import("./block-reply-pipeline.js");
+ const directlySentBlockKeys = new Set();
+ directlySentBlockKeys.add(
+ createBlockReplyContentKey({ text: "response", replyToId: "post-1" }),
+ );
+
+ const { replyPayloads } = await buildReplyPayloads({
+ ...baseParams,
+ blockStreamingEnabled: false,
+ blockReplyPipeline: null,
+ directlySentBlockKeys,
+ replyToMode: "off",
+ payloads: [{ text: "response" }],
+ });
+
+ expect(replyPayloads).toHaveLength(0);
+ });
+
it("does not suppress same-target replies when accountId differs", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts
index 263dea9fd54..9e89c921407 100644
--- a/src/auto-reply/reply/agent-runner-payloads.ts
+++ b/src/auto-reply/reply/agent-runner-payloads.ts
@@ -5,7 +5,7 @@ import type { OriginatingChannelType } from "../templating.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { ReplyPayload } from "../types.js";
import { formatBunFetchSocketError, isBunFetchSocketError } from "./agent-runner-utils.js";
-import { createBlockReplyPayloadKey, type BlockReplyPipeline } from "./block-reply-pipeline.js";
+import { createBlockReplyContentKey, type BlockReplyPipeline } from "./block-reply-pipeline.js";
import {
resolveOriginAccountId,
resolveOriginMessageProvider,
@@ -213,7 +213,7 @@ export async function buildReplyPayloads(params: {
)
: params.directlySentBlockKeys?.size
? mediaFilteredPayloads.filter(
- (payload) => !params.directlySentBlockKeys!.has(createBlockReplyPayloadKey(payload)),
+ (payload) => !params.directlySentBlockKeys!.has(createBlockReplyContentKey(payload)),
)
: mediaFilteredPayloads;
const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads;
diff --git a/src/auto-reply/reply/block-reply-pipeline.test.ts b/src/auto-reply/reply/block-reply-pipeline.test.ts
new file mode 100644
index 00000000000..92564033df5
--- /dev/null
+++ b/src/auto-reply/reply/block-reply-pipeline.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import {
+ createBlockReplyContentKey,
+ createBlockReplyPayloadKey,
+ createBlockReplyPipeline,
+} from "./block-reply-pipeline.js";
+
+describe("createBlockReplyPayloadKey", () => {
+ it("produces different keys for payloads differing only by replyToId", () => {
+ const a = createBlockReplyPayloadKey({ text: "hello world", replyToId: "post-1" });
+ const b = createBlockReplyPayloadKey({ text: "hello world", replyToId: "post-2" });
+ const c = createBlockReplyPayloadKey({ text: "hello world" });
+ expect(a).not.toBe(b);
+ expect(a).not.toBe(c);
+ });
+
+ it("produces different keys for payloads with different text", () => {
+ const a = createBlockReplyPayloadKey({ text: "hello" });
+ const b = createBlockReplyPayloadKey({ text: "world" });
+ expect(a).not.toBe(b);
+ });
+
+ it("produces different keys for payloads with different media", () => {
+ const a = createBlockReplyPayloadKey({ text: "hello", mediaUrl: "file:///a.png" });
+ const b = createBlockReplyPayloadKey({ text: "hello", mediaUrl: "file:///b.png" });
+ expect(a).not.toBe(b);
+ });
+
+ it("trims whitespace from text for key comparison", () => {
+ const a = createBlockReplyPayloadKey({ text: " hello " });
+ const b = createBlockReplyPayloadKey({ text: "hello" });
+ expect(a).toBe(b);
+ });
+});
+
+describe("createBlockReplyContentKey", () => {
+ it("produces the same key for payloads differing only by replyToId", () => {
+ const a = createBlockReplyContentKey({ text: "hello world", replyToId: "post-1" });
+ const b = createBlockReplyContentKey({ text: "hello world", replyToId: "post-2" });
+ const c = createBlockReplyContentKey({ text: "hello world" });
+ expect(a).toBe(b);
+ expect(a).toBe(c);
+ });
+});
+
+describe("createBlockReplyPipeline dedup with threading", () => {
+ it("keeps separate deliveries for same text with different replyToId", async () => {
+ const sent: Array<{ text?: string; replyToId?: string }> = [];
+ const pipeline = createBlockReplyPipeline({
+ onBlockReply: async (payload) => {
+ sent.push({ text: payload.text, replyToId: payload.replyToId });
+ },
+ timeoutMs: 5000,
+ });
+
+ pipeline.enqueue({ text: "response text", replyToId: "thread-root-1" });
+ pipeline.enqueue({ text: "response text", replyToId: undefined });
+ await pipeline.flush();
+
+ expect(sent).toEqual([
+ { text: "response text", replyToId: "thread-root-1" },
+ { text: "response text", replyToId: undefined },
+ ]);
+ });
+
+ it("hasSentPayload matches regardless of replyToId", async () => {
+ const pipeline = createBlockReplyPipeline({
+ onBlockReply: async () => {},
+ timeoutMs: 5000,
+ });
+
+ pipeline.enqueue({ text: "response text", replyToId: "thread-root-1" });
+ await pipeline.flush();
+
+ // Final payload with no replyToId should be recognized as already sent
+ expect(pipeline.hasSentPayload({ text: "response text" })).toBe(true);
+ expect(pipeline.hasSentPayload({ text: "response text", replyToId: "other-id" })).toBe(true);
+ });
+});
diff --git a/src/auto-reply/reply/block-reply-pipeline.ts b/src/auto-reply/reply/block-reply-pipeline.ts
index 752c70a1da2..9ce85334238 100644
--- a/src/auto-reply/reply/block-reply-pipeline.ts
+++ b/src/auto-reply/reply/block-reply-pipeline.ts
@@ -48,6 +48,19 @@ export function createBlockReplyPayloadKey(payload: ReplyPayload): string {
});
}
+export function createBlockReplyContentKey(payload: ReplyPayload): string {
+ const text = payload.text?.trim() ?? "";
+ const mediaList = payload.mediaUrls?.length
+ ? payload.mediaUrls
+ : payload.mediaUrl
+ ? [payload.mediaUrl]
+ : [];
+ // Content-only key used for final-payload suppression after block streaming.
+ // This intentionally ignores replyToId so a streamed threaded payload and the
+ // later final payload still collapse when they carry the same content.
+ return JSON.stringify({ text, mediaList });
+}
+
const withTimeout = async (
promise: Promise,
timeoutMs: number,
@@ -80,6 +93,7 @@ export function createBlockReplyPipeline(params: {
}): BlockReplyPipeline {
const { onBlockReply, timeoutMs, coalescing, buffer } = params;
const sentKeys = new Set();
+ const sentContentKeys = new Set();
const pendingKeys = new Set();
const seenKeys = new Set();
const bufferedKeys = new Set();
@@ -95,6 +109,7 @@ export function createBlockReplyPipeline(params: {
return;
}
const payloadKey = createBlockReplyPayloadKey(payload);
+ const contentKey = createBlockReplyContentKey(payload);
if (!bypassSeenCheck) {
if (seenKeys.has(payloadKey)) {
return;
@@ -130,6 +145,7 @@ export function createBlockReplyPipeline(params: {
return;
}
sentKeys.add(payloadKey);
+ sentContentKeys.add(contentKey);
didStream = true;
})
.catch((err) => {
@@ -238,8 +254,8 @@ export function createBlockReplyPipeline(params: {
didStream: () => didStream,
isAborted: () => aborted,
hasSentPayload: (payload) => {
- const payloadKey = createBlockReplyPayloadKey(payload);
- return sentKeys.has(payloadKey);
+ const payloadKey = createBlockReplyContentKey(payload);
+ return sentContentKeys.has(payloadKey);
},
};
}
diff --git a/src/auto-reply/reply/reply-delivery.ts b/src/auto-reply/reply/reply-delivery.ts
index acf04e73a3e..cacd6b083cb 100644
--- a/src/auto-reply/reply/reply-delivery.ts
+++ b/src/auto-reply/reply/reply-delivery.ts
@@ -2,7 +2,7 @@ import { logVerbose } from "../../globals.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { BlockReplyContext, ReplyPayload } from "../types.js";
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
-import { createBlockReplyPayloadKey } from "./block-reply-pipeline.js";
+import { createBlockReplyContentKey } from "./block-reply-pipeline.js";
import { parseReplyDirectives } from "./reply-directives.js";
import { applyReplyTagsToPayload, isRenderablePayload } from "./reply-payloads.js";
import type { TypingSignaler } from "./typing-mode.js";
@@ -128,7 +128,7 @@ export function createBlockReplyDeliveryHandler(params: {
} else if (params.blockStreamingEnabled) {
// Send directly when flushing before tool execution (no pipeline but streaming enabled).
// Track sent key to avoid duplicate in final payloads.
- params.directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload));
+ params.directlySentBlockKeys.add(createBlockReplyContentKey(blockPayload));
await params.onBlockReply(blockPayload);
}
// When streaming is disabled entirely, blocks are accumulated in final text instead.
diff --git a/src/browser/proxy-files.test.ts b/src/browser/proxy-files.test.ts
new file mode 100644
index 00000000000..1d7ea9566bb
--- /dev/null
+++ b/src/browser/proxy-files.test.ts
@@ -0,0 +1,54 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { MEDIA_MAX_BYTES } from "../media/store.js";
+import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
+import { persistBrowserProxyFiles } from "./proxy-files.js";
+
+describe("persistBrowserProxyFiles", () => {
+ let tempHome: TempHomeEnv;
+
+ beforeEach(async () => {
+ tempHome = await createTempHomeEnv("openclaw-browser-proxy-files-");
+ });
+
+ afterEach(async () => {
+ await tempHome.restore();
+ });
+
+ it("persists browser proxy files under the shared media store", async () => {
+ const sourcePath = "/tmp/proxy-file.txt";
+ const mapping = await persistBrowserProxyFiles([
+ {
+ path: sourcePath,
+ base64: Buffer.from("hello from browser proxy").toString("base64"),
+ mimeType: "text/plain",
+ },
+ ]);
+
+ const savedPath = mapping.get(sourcePath);
+ expect(typeof savedPath).toBe("string");
+ expect(path.normalize(savedPath ?? "")).toContain(
+ `${path.sep}.openclaw${path.sep}media${path.sep}browser${path.sep}`,
+ );
+ await expect(fs.readFile(savedPath ?? "", "utf8")).resolves.toBe("hello from browser proxy");
+ });
+
+ it("rejects browser proxy files that exceed the shared media size limit", async () => {
+ const oversized = Buffer.alloc(MEDIA_MAX_BYTES + 1, 0x41);
+
+ await expect(
+ persistBrowserProxyFiles([
+ {
+ path: "/tmp/oversized.bin",
+ base64: oversized.toString("base64"),
+ mimeType: "application/octet-stream",
+ },
+ ]),
+ ).rejects.toThrow("Media exceeds 5MB limit");
+
+ await expect(
+ fs.stat(path.join(tempHome.home, ".openclaw", "media", "browser")),
+ ).rejects.toThrow();
+ });
+});
diff --git a/src/browser/proxy-files.ts b/src/browser/proxy-files.ts
index b18820a4594..1d39d71a09e 100644
--- a/src/browser/proxy-files.ts
+++ b/src/browser/proxy-files.ts
@@ -13,7 +13,7 @@ export async function persistBrowserProxyFiles(files: BrowserProxyFile[] | undef
const mapping = new Map();
for (const file of files) {
const buffer = Buffer.from(file.base64, "base64");
- const saved = await saveMediaBuffer(buffer, file.mimeType, "browser", buffer.byteLength);
+ const saved = await saveMediaBuffer(buffer, file.mimeType, "browser");
mapping.set(file.path, saved.path);
}
return mapping;
diff --git a/src/channels/plugins/onboarding/helpers.ts b/src/channels/plugins/onboarding/helpers.ts
index 6eab25fd239..77d03a4127a 100644
--- a/src/channels/plugins/onboarding/helpers.ts
+++ b/src/channels/plugins/onboarding/helpers.ts
@@ -164,11 +164,11 @@ export function setAccountAllowFromForChannel(params: {
});
}
-export function setTopLevelChannelAllowFrom(params: {
+function patchTopLevelChannelConfig(params: {
cfg: OpenClawConfig;
channel: string;
- allowFrom: string[];
enabled?: boolean;
+ patch: Record;
}): OpenClawConfig {
const channelConfig =
(params.cfg.channels?.[params.channel] as Record | undefined) ?? {};
@@ -179,12 +179,26 @@ export function setTopLevelChannelAllowFrom(params: {
[params.channel]: {
...channelConfig,
...(params.enabled ? { enabled: true } : {}),
- allowFrom: params.allowFrom,
+ ...params.patch,
},
},
};
}
+export function setTopLevelChannelAllowFrom(params: {
+ cfg: OpenClawConfig;
+ channel: string;
+ allowFrom: string[];
+ enabled?: boolean;
+}): OpenClawConfig {
+ return patchTopLevelChannelConfig({
+ cfg: params.cfg,
+ channel: params.channel,
+ enabled: params.enabled,
+ patch: { allowFrom: params.allowFrom },
+ });
+}
+
export function setTopLevelChannelDmPolicyWithAllowFrom(params: {
cfg: OpenClawConfig;
channel: string;
@@ -199,17 +213,14 @@ export function setTopLevelChannelDmPolicyWithAllowFrom(params: {
undefined;
const allowFrom =
params.dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined;
- return {
- ...params.cfg,
- channels: {
- ...params.cfg.channels,
- [params.channel]: {
- ...channelConfig,
- dmPolicy: params.dmPolicy,
- ...(allowFrom ? { allowFrom } : {}),
- },
+ return patchTopLevelChannelConfig({
+ cfg: params.cfg,
+ channel: params.channel,
+ patch: {
+ dmPolicy: params.dmPolicy,
+ ...(allowFrom ? { allowFrom } : {}),
},
- };
+ });
}
export function setTopLevelChannelGroupPolicy(params: {
@@ -218,19 +229,12 @@ export function setTopLevelChannelGroupPolicy(params: {
groupPolicy: GroupPolicy;
enabled?: boolean;
}): OpenClawConfig {
- const channelConfig =
- (params.cfg.channels?.[params.channel] as Record | undefined) ?? {};
- return {
- ...params.cfg,
- channels: {
- ...params.cfg.channels,
- [params.channel]: {
- ...channelConfig,
- ...(params.enabled ? { enabled: true } : {}),
- groupPolicy: params.groupPolicy,
- },
- },
- };
+ return patchTopLevelChannelConfig({
+ cfg: params.cfg,
+ channel: params.channel,
+ enabled: params.enabled,
+ patch: { groupPolicy: params.groupPolicy },
+ });
}
export function setChannelDmPolicyWithAllowFrom(params: {
diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts
index 9e39eeca30e..045281bc7d1 100644
--- a/src/cli/skills-cli.format.ts
+++ b/src/cli/skills-cli.format.ts
@@ -69,7 +69,6 @@ function sanitizeJsonValue(value: unknown): unknown {
}
return value;
}
-
function formatSkillName(skill: SkillStatusEntry): string {
const emoji = normalizeSkillEmoji(skill.emoji);
return `${emoji} ${theme.command(skill.name)}`;
diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts
index 817f9efc3d8..0ae44b2db7a 100644
--- a/src/config/sessions/types.ts
+++ b/src/config/sessions/types.ts
@@ -78,6 +78,8 @@ export type SessionEntry = {
sessionFile?: string;
/** Parent session key that spawned this session (used for sandbox session-tool scoping). */
spawnedBy?: string;
+ /** Workspace inherited by spawned sessions and reused on later turns for the same child session. */
+ spawnedWorkspaceDir?: string;
/** True after a thread/topic session has been forked from its parent transcript once. */
forkedFromParent?: boolean;
/** Subagent spawn depth (0 = main, 1 = sub-agent, 2 = sub-sub-agent). */
diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts
index 58b061682a1..422bbc82eed 100644
--- a/src/config/types.gateway.ts
+++ b/src/config/types.gateway.ts
@@ -186,6 +186,8 @@ export type GatewayTailscaleConfig = {
};
export type GatewayRemoteConfig = {
+ /** Whether remote gateway surfaces are enabled. Default: true when absent. */
+ enabled?: boolean;
/** Remote Gateway WebSocket URL (ws:// or wss://). */
url?: string;
/** Transport for macOS remote connections (ssh tunnel or direct WS). */
diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts
index 9da88bbb4a3..2c7eb20a3c6 100644
--- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts
+++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts
@@ -217,6 +217,9 @@ describe("dispatchCronDelivery — double-announce guard", () => {
payloads: [{ text: "Detailed child result, everything finished successfully." }],
}),
);
+ expect(deliverOutboundPayloads).toHaveBeenCalledWith(
+ expect.objectContaining({ skipQueue: true }),
+ );
});
it("normal text delivery sends exactly once and sets deliveryAttempted=true", async () => {
@@ -304,4 +307,69 @@ describe("dispatchCronDelivery — double-announce guard", () => {
expect(deliverOutboundPayloads).not.toHaveBeenCalled();
expect(state.deliveryAttempted).toBe(false);
});
+
+ it("text delivery always bypasses the write-ahead queue", async () => {
+ vi.mocked(countActiveDescendantRuns).mockReturnValue(0);
+ vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false);
+ vi.mocked(deliverOutboundPayloads).mockResolvedValue([{ ok: true } as never]);
+
+ const params = makeBaseParams({ synthesizedText: "Daily digest ready." });
+ const state = await dispatchCronDelivery(params);
+
+ expect(state.delivered).toBe(true);
+ expect(state.deliveryAttempted).toBe(true);
+ expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1);
+
+ expect(deliverOutboundPayloads).toHaveBeenCalledWith(
+ expect.objectContaining({
+ channel: "telegram",
+ to: "123456",
+ payloads: [{ text: "Daily digest ready." }],
+ skipQueue: true,
+ }),
+ );
+ });
+
+ it("structured/thread delivery also bypasses the write-ahead queue", async () => {
+ vi.mocked(countActiveDescendantRuns).mockReturnValue(0);
+ vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false);
+ vi.mocked(deliverOutboundPayloads).mockResolvedValue([{ ok: true } as never]);
+
+ const params = makeBaseParams({ synthesizedText: "Report attached." });
+ // Simulate structured content so useDirectDelivery path is taken (no retryTransient)
+ (params as Record).deliveryPayloadHasStructuredContent = true;
+ await dispatchCronDelivery(params);
+
+ expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1);
+ expect(deliverOutboundPayloads).toHaveBeenCalledWith(
+ expect.objectContaining({ skipQueue: true }),
+ );
+ });
+
+ it("transient retry delivers exactly once with skipQueue on both attempts", async () => {
+ vi.mocked(countActiveDescendantRuns).mockReturnValue(0);
+ vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false);
+
+ // First call throws a transient error, second call succeeds.
+ vi.mocked(deliverOutboundPayloads)
+ .mockRejectedValueOnce(new Error("gateway timeout"))
+ .mockResolvedValueOnce([{ ok: true } as never]);
+
+ vi.stubEnv("OPENCLAW_TEST_FAST", "1");
+ try {
+ const params = makeBaseParams({ synthesizedText: "Retry test." });
+ const state = await dispatchCronDelivery(params);
+
+ expect(state.delivered).toBe(true);
+ expect(state.deliveryAttempted).toBe(true);
+ // Two calls total: first failed transiently, second succeeded.
+ expect(deliverOutboundPayloads).toHaveBeenCalledTimes(2);
+
+ const calls = vi.mocked(deliverOutboundPayloads).mock.calls;
+ expect(calls[0][0]).toEqual(expect.objectContaining({ skipQueue: true }));
+ expect(calls[1][0]).toEqual(expect.objectContaining({ skipQueue: true }));
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
});
diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts
index fa9a295a777..a5dc0190b72 100644
--- a/src/cron/isolated-agent/delivery-dispatch.ts
+++ b/src/cron/isolated-agent/delivery-dispatch.ts
@@ -157,7 +157,9 @@ function isTransientDirectCronDeliveryError(error: unknown): boolean {
}
function resolveDirectCronRetryDelaysMs(): readonly number[] {
- return process.env.OPENCLAW_TEST_FAST === "1" ? [8, 16, 32] : [5_000, 10_000, 20_000];
+ return process.env.NODE_ENV === "test" && process.env.OPENCLAW_TEST_FAST === "1"
+ ? [8, 16, 32]
+ : [5_000, 10_000, 20_000];
}
async function retryTransientDirectCronDelivery(params: {
@@ -256,6 +258,12 @@ export async function dispatchCronDelivery(
bestEffort: params.deliveryBestEffort,
deps: createOutboundSendDeps(params.deps),
abortSignal: params.abortSignal,
+ // Isolated cron direct delivery uses its own transient retry loop.
+ // Keep all attempts out of the write-ahead delivery queue so a
+ // late-successful first send cannot leave behind a failed queue
+ // entry that replays on the next restart.
+ // See: https://github.com/openclaw/openclaw/issues/40545
+ skipQueue: true,
});
const deliveryResults = options?.retryTransient
? await retryTransientDirectCronDelivery({
diff --git a/src/gateway/chat-abort.ts b/src/gateway/chat-abort.ts
index 0210f9223f7..4be479153f6 100644
--- a/src/gateway/chat-abort.ts
+++ b/src/gateway/chat-abort.ts
@@ -6,6 +6,8 @@ export type ChatAbortControllerEntry = {
sessionKey: string;
startedAtMs: number;
expiresAtMs: number;
+ ownerConnId?: string;
+ ownerDeviceId?: string;
};
export function isChatStopCommandText(text: string): boolean {
diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts
index 75d560ba92b..11369a4ed4a 100644
--- a/src/gateway/protocol/schema/agent.ts
+++ b/src/gateway/protocol/schema/agent.ts
@@ -1,6 +1,5 @@
import { Type } from "@sinclair/typebox";
-import { INPUT_PROVENANCE_KIND_VALUES } from "../../../sessions/input-provenance.js";
-import { NonEmptyString, SessionLabelString } from "./primitives.js";
+import { InputProvenanceSchema, NonEmptyString, SessionLabelString } from "./primitives.js";
export const AgentInternalEventSchema = Type.Object(
{
@@ -96,22 +95,9 @@ export const AgentParamsSchema = Type.Object(
lane: Type.Optional(Type.String()),
extraSystemPrompt: Type.Optional(Type.String()),
internalEvents: Type.Optional(Type.Array(AgentInternalEventSchema)),
- inputProvenance: Type.Optional(
- Type.Object(
- {
- kind: Type.String({ enum: [...INPUT_PROVENANCE_KIND_VALUES] }),
- originSessionId: Type.Optional(Type.String()),
- sourceSessionKey: Type.Optional(Type.String()),
- sourceChannel: Type.Optional(Type.String()),
- sourceTool: Type.Optional(Type.String()),
- },
- { additionalProperties: false },
- ),
- ),
+ inputProvenance: Type.Optional(InputProvenanceSchema),
idempotencyKey: NonEmptyString,
label: Type.Optional(SessionLabelString),
- spawnedBy: Type.Optional(Type.String()),
- workspaceDir: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
diff --git a/src/gateway/protocol/schema/logs-chat.ts b/src/gateway/protocol/schema/logs-chat.ts
index 5545bd443f1..5c4003acb8e 100644
--- a/src/gateway/protocol/schema/logs-chat.ts
+++ b/src/gateway/protocol/schema/logs-chat.ts
@@ -1,6 +1,5 @@
import { Type } from "@sinclair/typebox";
-import { INPUT_PROVENANCE_KIND_VALUES } from "../../../sessions/input-provenance.js";
-import { ChatSendSessionKeyString, NonEmptyString } from "./primitives.js";
+import { ChatSendSessionKeyString, InputProvenanceSchema, NonEmptyString } from "./primitives.js";
export const LogsTailParamsSchema = Type.Object(
{
@@ -40,18 +39,7 @@ export const ChatSendParamsSchema = Type.Object(
deliver: Type.Optional(Type.Boolean()),
attachments: Type.Optional(Type.Array(Type.Unknown())),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
- systemInputProvenance: Type.Optional(
- Type.Object(
- {
- kind: Type.String({ enum: [...INPUT_PROVENANCE_KIND_VALUES] }),
- originSessionId: Type.Optional(Type.String()),
- sourceSessionKey: Type.Optional(Type.String()),
- sourceChannel: Type.Optional(Type.String()),
- sourceTool: Type.Optional(Type.String()),
- },
- { additionalProperties: false },
- ),
- ),
+ systemInputProvenance: Type.Optional(InputProvenanceSchema),
systemProvenanceReceipt: Type.Optional(Type.String()),
idempotencyKey: NonEmptyString,
},
diff --git a/src/gateway/protocol/schema/primitives.ts b/src/gateway/protocol/schema/primitives.ts
index 6ac6a71b64a..2983c834f35 100644
--- a/src/gateway/protocol/schema/primitives.ts
+++ b/src/gateway/protocol/schema/primitives.ts
@@ -5,6 +5,7 @@ import {
FILE_SECRET_REF_ID_PATTERN,
SECRET_PROVIDER_ALIAS_PATTERN,
} from "../../../secrets/ref-contract.js";
+import { INPUT_PROVENANCE_KIND_VALUES } from "../../../sessions/input-provenance.js";
import { SESSION_LABEL_MAX_LENGTH } from "../../../sessions/session-label.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../client-info.js";
@@ -18,6 +19,16 @@ export const SessionLabelString = Type.String({
minLength: 1,
maxLength: SESSION_LABEL_MAX_LENGTH,
});
+export const InputProvenanceSchema = Type.Object(
+ {
+ kind: Type.String({ enum: [...INPUT_PROVENANCE_KIND_VALUES] }),
+ originSessionId: Type.Optional(Type.String()),
+ sourceSessionKey: Type.Optional(Type.String()),
+ sourceChannel: Type.Optional(Type.String()),
+ sourceTool: Type.Optional(Type.String()),
+ },
+ { additionalProperties: false },
+);
export const GatewayClientIdSchema = Type.Union(
Object.values(GATEWAY_CLIENT_IDS).map((value) => Type.Literal(value)),
diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts
index 83f09e8ecba..30595c15698 100644
--- a/src/gateway/protocol/schema/sessions.ts
+++ b/src/gateway/protocol/schema/sessions.ts
@@ -71,6 +71,7 @@ export const SessionsPatchParamsSchema = Type.Object(
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
+ spawnedWorkspaceDir: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnDepth: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.Null()])),
subagentRole: Type.Optional(
Type.Union([Type.Literal("orchestrator"), Type.Literal("leaf"), Type.Null()]),
diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts
index fbc8b056c34..5dfa27b20ce 100644
--- a/src/gateway/server-methods/agent.test.ts
+++ b/src/gateway/server-methods/agent.test.ts
@@ -405,30 +405,53 @@ describe("gateway agent handler", () => {
expect(callArgs.bestEffortDeliver).toBe(false);
});
- it("only forwards workspaceDir for spawned subagent runs", async () => {
+ it("rejects public spawned-run metadata fields", async () => {
primeMainAgentRun();
mocks.agentCommand.mockClear();
-
- await invokeAgent(
- {
- message: "normal run",
- sessionKey: "agent:main:main",
- workspaceDir: "/tmp/ignored",
- idempotencyKey: "workspace-ignored",
- },
- { reqId: "workspace-ignored-1" },
- );
- await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
- const normalCall = mocks.agentCommand.mock.calls.at(-1)?.[0] as { workspaceDir?: string };
- expect(normalCall.workspaceDir).toBeUndefined();
- mocks.agentCommand.mockClear();
+ const respond = vi.fn();
await invokeAgent(
{
message: "spawned run",
sessionKey: "agent:main:main",
spawnedBy: "agent:main:subagent:parent",
- workspaceDir: "/tmp/inherited",
+ workspaceDir: "/tmp/injected",
+ idempotencyKey: "workspace-rejected",
+ } as AgentParams,
+ { reqId: "workspace-rejected-1", respond },
+ );
+
+ expect(mocks.agentCommand).not.toHaveBeenCalled();
+ expect(respond).toHaveBeenCalledWith(
+ false,
+ undefined,
+ expect.objectContaining({
+ message: expect.stringContaining("invalid agent params"),
+ }),
+ );
+ });
+
+ it("only forwards workspaceDir for spawned sessions with stored workspace inheritance", async () => {
+ primeMainAgentRun();
+ mockMainSessionEntry({
+ spawnedBy: "agent:main:subagent:parent",
+ spawnedWorkspaceDir: "/tmp/inherited",
+ });
+ mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
+ const store: Record = {
+ "agent:main:main": buildExistingMainStoreEntry({
+ spawnedBy: "agent:main:subagent:parent",
+ spawnedWorkspaceDir: "/tmp/inherited",
+ }),
+ };
+ return await updater(store);
+ });
+ mocks.agentCommand.mockClear();
+
+ await invokeAgent(
+ {
+ message: "spawned run",
+ sessionKey: "agent:main:main",
idempotencyKey: "workspace-forwarded",
},
{ reqId: "workspace-forwarded-1" },
diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts
index a6d437e6792..98466f91044 100644
--- a/src/gateway/server-methods/agent.ts
+++ b/src/gateway/server-methods/agent.ts
@@ -190,24 +190,20 @@ export const agentHandlers: GatewayRequestHandlers = {
timeout?: number;
bestEffortDeliver?: boolean;
label?: string;
- spawnedBy?: string;
inputProvenance?: InputProvenance;
- workspaceDir?: string;
};
const senderIsOwner = resolveSenderIsOwnerFromClient(client);
const cfg = loadConfig();
const idem = request.idempotencyKey;
const normalizedSpawned = normalizeSpawnedRunMetadata({
- spawnedBy: request.spawnedBy,
groupId: request.groupId,
groupChannel: request.groupChannel,
groupSpace: request.groupSpace,
- workspaceDir: request.workspaceDir,
});
let resolvedGroupId: string | undefined = normalizedSpawned.groupId;
let resolvedGroupChannel: string | undefined = normalizedSpawned.groupChannel;
let resolvedGroupSpace: string | undefined = normalizedSpawned.groupSpace;
- let spawnedByValue = normalizedSpawned.spawnedBy;
+ let spawnedByValue: string | undefined;
const inputProvenance = normalizeInputProvenance(request.inputProvenance);
const cached = context.dedupe.get(`agent:${idem}`);
if (cached) {
@@ -359,11 +355,7 @@ export const agentHandlers: GatewayRequestHandlers = {
const sessionId = entry?.sessionId ?? randomUUID();
const labelValue = request.label?.trim() || entry?.label;
const sessionAgent = resolveAgentIdFromSessionKey(canonicalKey);
- spawnedByValue = canonicalizeSpawnedByForAgent(
- cfg,
- sessionAgent,
- spawnedByValue || entry?.spawnedBy,
- );
+ spawnedByValue = canonicalizeSpawnedByForAgent(cfg, sessionAgent, entry?.spawnedBy);
let inheritedGroup:
| { groupId?: string; groupChannel?: string; groupSpace?: string }
| undefined;
@@ -400,6 +392,7 @@ export const agentHandlers: GatewayRequestHandlers = {
providerOverride: entry?.providerOverride,
label: labelValue,
spawnedBy: spawnedByValue,
+ spawnedWorkspaceDir: entry?.spawnedWorkspaceDir,
spawnDepth: entry?.spawnDepth,
channel: entry?.channel ?? request.channel?.trim(),
groupId: resolvedGroupId ?? entry?.groupId,
@@ -628,7 +621,7 @@ export const agentHandlers: GatewayRequestHandlers = {
// Internal-only: allow workspace override for spawned subagent runs.
workspaceDir: resolveIngressWorkspaceOverrideForSpawnedRun({
spawnedBy: spawnedByValue,
- workspaceDir: request.workspaceDir,
+ workspaceDir: sessionEntry?.spawnedWorkspaceDir,
}),
senderIsOwner,
},
diff --git a/src/gateway/server-methods/browser.profile-from-body.test.ts b/src/gateway/server-methods/browser.profile-from-body.test.ts
index 972fca9f848..3b2caf8dbdc 100644
--- a/src/gateway/server-methods/browser.profile-from-body.test.ts
+++ b/src/gateway/server-methods/browser.profile-from-body.test.ts
@@ -100,4 +100,42 @@ describe("browser.request profile selection", () => {
}),
);
});
+
+ it.each([
+ {
+ method: "POST",
+ path: "/profiles/create",
+ body: { name: "poc", cdpUrl: "http://10.0.0.42:9222" },
+ },
+ {
+ method: "DELETE",
+ path: "/profiles/poc",
+ body: undefined,
+ },
+ {
+ method: "POST",
+ path: "profiles/create",
+ body: { name: "poc", cdpUrl: "http://10.0.0.42:9222" },
+ },
+ {
+ method: "DELETE",
+ path: "profiles/poc",
+ body: undefined,
+ },
+ ])("blocks persistent profile mutations for $method $path", async ({ method, path, body }) => {
+ const { respond, nodeRegistry } = await runBrowserRequest({
+ method,
+ path,
+ body,
+ });
+
+ expect(nodeRegistry.invoke).not.toHaveBeenCalled();
+ expect(respond).toHaveBeenCalledWith(
+ false,
+ undefined,
+ expect.objectContaining({
+ message: "browser.request cannot create or delete persistent browser profiles",
+ }),
+ );
+ });
});
diff --git a/src/gateway/server-methods/browser.ts b/src/gateway/server-methods/browser.ts
index bda77ad98e4..0bb2db3dafd 100644
--- a/src/gateway/server-methods/browser.ts
+++ b/src/gateway/server-methods/browser.ts
@@ -20,6 +20,26 @@ type BrowserRequestParams = {
timeoutMs?: number;
};
+function normalizeBrowserRequestPath(value: string): string {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return trimmed;
+ }
+ const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
+ if (withLeadingSlash.length <= 1) {
+ return withLeadingSlash;
+ }
+ return withLeadingSlash.replace(/\/+$/, "");
+}
+
+function isPersistentBrowserProfileMutation(method: string, path: string): boolean {
+ const normalizedPath = normalizeBrowserRequestPath(path);
+ if (method === "POST" && normalizedPath === "/profiles/create") {
+ return true;
+ }
+ return method === "DELETE" && /^\/profiles\/[^/]+$/.test(normalizedPath);
+}
+
function resolveRequestedProfile(params: {
query?: Record;
body?: unknown;
@@ -167,6 +187,17 @@ export const browserHandlers: GatewayRequestHandlers = {
);
return;
}
+ if (isPersistentBrowserProfileMutation(methodRaw, path)) {
+ respond(
+ false,
+ undefined,
+ errorShape(
+ ErrorCodes.INVALID_REQUEST,
+ "browser.request cannot create or delete persistent browser profiles",
+ ),
+ );
+ return;
+ }
const cfg = loadConfig();
let nodeTarget: NodeSession | null = null;
diff --git a/src/gateway/server-methods/chat.abort-authorization.test.ts b/src/gateway/server-methods/chat.abort-authorization.test.ts
new file mode 100644
index 00000000000..6fbf0478df3
--- /dev/null
+++ b/src/gateway/server-methods/chat.abort-authorization.test.ts
@@ -0,0 +1,147 @@
+import { describe, expect, it, vi } from "vitest";
+import { chatHandlers } from "./chat.js";
+
+function createActiveRun(sessionKey: string, owner?: { connId?: string; deviceId?: string }) {
+ const now = Date.now();
+ return {
+ controller: new AbortController(),
+ sessionId: `${sessionKey}-session`,
+ sessionKey,
+ startedAtMs: now,
+ expiresAtMs: now + 30_000,
+ ownerConnId: owner?.connId,
+ ownerDeviceId: owner?.deviceId,
+ };
+}
+
+function createContext(overrides: Record = {}) {
+ return {
+ chatAbortControllers: new Map(),
+ chatRunBuffers: new Map(),
+ chatDeltaSentAt: new Map(),
+ chatAbortedRuns: new Map(),
+ removeChatRun: vi
+ .fn()
+ .mockImplementation((run: string) => ({ sessionKey: "main", clientRunId: run })),
+ agentRunSeq: new Map(),
+ broadcast: vi.fn(),
+ nodeSendToSession: vi.fn(),
+ logGateway: { warn: vi.fn() },
+ ...overrides,
+ };
+}
+
+async function invokeChatAbort(params: {
+ context: ReturnType;
+ request: { sessionKey: string; runId?: string };
+ client?: {
+ connId?: string;
+ connect?: {
+ device?: { id?: string };
+ scopes?: string[];
+ };
+ } | null;
+}) {
+ const respond = vi.fn();
+ await chatHandlers["chat.abort"]({
+ params: params.request,
+ respond: respond as never,
+ context: params.context as never,
+ req: {} as never,
+ client: (params.client ?? null) as never,
+ isWebchatConnect: () => false,
+ });
+ return respond;
+}
+
+describe("chat.abort authorization", () => {
+ it("rejects explicit run aborts from other clients", async () => {
+ const context = createContext({
+ chatAbortControllers: new Map([
+ ["run-1", createActiveRun("main", { connId: "conn-owner", deviceId: "dev-owner" })],
+ ]),
+ });
+
+ const respond = await invokeChatAbort({
+ context,
+ request: { sessionKey: "main", runId: "run-1" },
+ client: {
+ connId: "conn-other",
+ connect: { device: { id: "dev-other" }, scopes: ["operator.write"] },
+ },
+ });
+
+ const [ok, payload, error] = respond.mock.calls.at(-1) ?? [];
+ expect(ok).toBe(false);
+ expect(payload).toBeUndefined();
+ expect(error).toMatchObject({ code: "INVALID_REQUEST", message: "unauthorized" });
+ expect(context.chatAbortControllers.has("run-1")).toBe(true);
+ });
+
+ it("allows the same paired device to abort after reconnecting", async () => {
+ const context = createContext({
+ chatAbortControllers: new Map([
+ ["run-1", createActiveRun("main", { connId: "conn-old", deviceId: "dev-1" })],
+ ]),
+ });
+
+ const respond = await invokeChatAbort({
+ context,
+ request: { sessionKey: "main", runId: "run-1" },
+ client: {
+ connId: "conn-new",
+ connect: { device: { id: "dev-1" }, scopes: ["operator.write"] },
+ },
+ });
+
+ const [ok, payload] = respond.mock.calls.at(-1) ?? [];
+ expect(ok).toBe(true);
+ expect(payload).toMatchObject({ aborted: true, runIds: ["run-1"] });
+ expect(context.chatAbortControllers.has("run-1")).toBe(false);
+ });
+
+ it("only aborts session-scoped runs owned by the requester", async () => {
+ const context = createContext({
+ chatAbortControllers: new Map([
+ ["run-mine", createActiveRun("main", { deviceId: "dev-1" })],
+ ["run-other", createActiveRun("main", { deviceId: "dev-2" })],
+ ]),
+ });
+
+ const respond = await invokeChatAbort({
+ context,
+ request: { sessionKey: "main" },
+ client: {
+ connId: "conn-1",
+ connect: { device: { id: "dev-1" }, scopes: ["operator.write"] },
+ },
+ });
+
+ const [ok, payload] = respond.mock.calls.at(-1) ?? [];
+ expect(ok).toBe(true);
+ expect(payload).toMatchObject({ aborted: true, runIds: ["run-mine"] });
+ expect(context.chatAbortControllers.has("run-mine")).toBe(false);
+ expect(context.chatAbortControllers.has("run-other")).toBe(true);
+ });
+
+ it("allows operator.admin clients to bypass owner checks", async () => {
+ const context = createContext({
+ chatAbortControllers: new Map([
+ ["run-1", createActiveRun("main", { connId: "conn-owner", deviceId: "dev-owner" })],
+ ]),
+ });
+
+ const respond = await invokeChatAbort({
+ context,
+ request: { sessionKey: "main", runId: "run-1" },
+ client: {
+ connId: "conn-admin",
+ connect: { device: { id: "dev-admin" }, scopes: ["operator.admin"] },
+ },
+ });
+
+ const [ok, payload] = respond.mock.calls.at(-1) ?? [];
+ expect(ok).toBe(true);
+ expect(payload).toMatchObject({ aborted: true, runIds: ["run-1"] });
+ });
+});
diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts
index 71669080382..13f3b997892 100644
--- a/src/gateway/server-methods/chat.ts
+++ b/src/gateway/server-methods/chat.ts
@@ -25,7 +25,6 @@ import {
} from "../../utils/message-channel.js";
import {
abortChatRunById,
- abortChatRunsForSessionKey,
type ChatAbortControllerEntry,
type ChatAbortOps,
isChatStopCommandText,
@@ -33,6 +32,7 @@ import {
} from "../chat-abort.js";
import { type ChatImageContent, parseMessageWithAttachments } from "../chat-attachments.js";
import { stripEnvelopeFromMessage, stripEnvelopeFromMessages } from "../chat-sanitize.js";
+import { ADMIN_SCOPE } from "../method-scopes.js";
import {
GATEWAY_CLIENT_CAPS,
GATEWAY_CLIENT_MODES,
@@ -83,6 +83,12 @@ type AbortedPartialSnapshot = {
abortOrigin: AbortOrigin;
};
+type ChatAbortRequester = {
+ connId?: string;
+ deviceId?: string;
+ isAdmin: boolean;
+};
+
const CHAT_HISTORY_TEXT_MAX_CHARS = 12_000;
const CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES = 128 * 1024;
const CHAT_HISTORY_OVERSIZED_PLACEHOLDER = "[chat.history omitted: message too large]";
@@ -314,6 +320,68 @@ function sanitizeChatHistoryContentBlock(block: unknown): { block: unknown; chan
return { block: changed ? entry : block, changed };
}
+/**
+ * Validate that a value is a finite number, returning undefined otherwise.
+ */
+function toFiniteNumber(x: unknown): number | undefined {
+ return typeof x === "number" && Number.isFinite(x) ? x : undefined;
+}
+
+/**
+ * Sanitize usage metadata to ensure only finite numeric fields are included.
+ * Prevents UI crashes from malformed transcript JSON.
+ */
+function sanitizeUsage(raw: unknown): Record | undefined {
+ if (!raw || typeof raw !== "object") {
+ return undefined;
+ }
+ const u = raw as Record;
+ const out: Record = {};
+
+ // Whitelist known usage fields and validate they're finite numbers
+ const knownFields = [
+ "input",
+ "output",
+ "totalTokens",
+ "inputTokens",
+ "outputTokens",
+ "cacheRead",
+ "cacheWrite",
+ "cache_read_input_tokens",
+ "cache_creation_input_tokens",
+ ];
+
+ for (const k of knownFields) {
+ const n = toFiniteNumber(u[k]);
+ if (n !== undefined) {
+ out[k] = n;
+ }
+ }
+
+ // Preserve nested usage.cost when present
+ if ("cost" in u && u.cost != null && typeof u.cost === "object") {
+ const sanitizedCost = sanitizeCost(u.cost);
+ if (sanitizedCost) {
+ (out as Record).cost = sanitizedCost;
+ }
+ }
+
+ return Object.keys(out).length > 0 ? out : undefined;
+}
+
+/**
+ * Sanitize cost metadata to ensure only finite numeric fields are included.
+ * Prevents UI crashes from calling .toFixed() on non-numbers.
+ */
+function sanitizeCost(raw: unknown): { total?: number } | undefined {
+ if (!raw || typeof raw !== "object") {
+ return undefined;
+ }
+ const c = raw as Record;
+ const total = toFiniteNumber(c.total);
+ return total !== undefined ? { total } : undefined;
+}
+
function sanitizeChatHistoryMessage(message: unknown): { message: unknown; changed: boolean } {
if (!message || typeof message !== "object") {
return { message, changed: false };
@@ -325,13 +393,38 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang
delete entry.details;
changed = true;
}
- if ("usage" in entry) {
- delete entry.usage;
- changed = true;
- }
- if ("cost" in entry) {
- delete entry.cost;
- changed = true;
+
+ // Keep usage/cost so the chat UI can render per-message token and cost badges.
+ // Only retain usage/cost on assistant messages and validate numeric fields to prevent UI crashes.
+ if (entry.role !== "assistant") {
+ if ("usage" in entry) {
+ delete entry.usage;
+ changed = true;
+ }
+ if ("cost" in entry) {
+ delete entry.cost;
+ changed = true;
+ }
+ } else {
+ // Validate and sanitize usage/cost for assistant messages
+ if ("usage" in entry) {
+ const sanitized = sanitizeUsage(entry.usage);
+ if (sanitized) {
+ entry.usage = sanitized;
+ } else {
+ delete entry.usage;
+ }
+ changed = true;
+ }
+ if ("cost" in entry) {
+ const sanitized = sanitizeCost(entry.cost);
+ if (sanitized) {
+ entry.cost = sanitized;
+ } else {
+ delete entry.cost;
+ }
+ changed = true;
+ }
}
if (typeof entry.content === "string") {
@@ -597,12 +690,12 @@ function appendAssistantTranscriptMessage(params: {
function collectSessionAbortPartials(params: {
chatAbortControllers: Map;
chatRunBuffers: Map;
- sessionKey: string;
+ runIds: ReadonlySet;
abortOrigin: AbortOrigin;
}): AbortedPartialSnapshot[] {
const out: AbortedPartialSnapshot[] = [];
for (const [runId, active] of params.chatAbortControllers) {
- if (active.sessionKey !== params.sessionKey) {
+ if (!params.runIds.has(runId)) {
continue;
}
const text = params.chatRunBuffers.get(runId);
@@ -664,23 +757,104 @@ function createChatAbortOps(context: GatewayRequestContext): ChatAbortOps {
};
}
+function normalizeOptionalText(value?: string | null): string | undefined {
+ const trimmed = value?.trim();
+ return trimmed || undefined;
+}
+
+function resolveChatAbortRequester(
+ client: GatewayRequestHandlerOptions["client"],
+): ChatAbortRequester {
+ const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
+ return {
+ connId: normalizeOptionalText(client?.connId),
+ deviceId: normalizeOptionalText(client?.connect?.device?.id),
+ isAdmin: scopes.includes(ADMIN_SCOPE),
+ };
+}
+
+function canRequesterAbortChatRun(
+ entry: ChatAbortControllerEntry,
+ requester: ChatAbortRequester,
+): boolean {
+ if (requester.isAdmin) {
+ return true;
+ }
+ const ownerDeviceId = normalizeOptionalText(entry.ownerDeviceId);
+ const ownerConnId = normalizeOptionalText(entry.ownerConnId);
+ if (!ownerDeviceId && !ownerConnId) {
+ return true;
+ }
+ if (ownerDeviceId && requester.deviceId && ownerDeviceId === requester.deviceId) {
+ return true;
+ }
+ if (ownerConnId && requester.connId && ownerConnId === requester.connId) {
+ return true;
+ }
+ return false;
+}
+
+function resolveAuthorizedRunIdsForSession(params: {
+ chatAbortControllers: Map;
+ sessionKey: string;
+ requester: ChatAbortRequester;
+}) {
+ const authorizedRunIds: string[] = [];
+ let matchedSessionRuns = 0;
+ for (const [runId, active] of params.chatAbortControllers) {
+ if (active.sessionKey !== params.sessionKey) {
+ continue;
+ }
+ matchedSessionRuns += 1;
+ if (canRequesterAbortChatRun(active, params.requester)) {
+ authorizedRunIds.push(runId);
+ }
+ }
+ return {
+ matchedSessionRuns,
+ authorizedRunIds,
+ };
+}
+
function abortChatRunsForSessionKeyWithPartials(params: {
context: GatewayRequestContext;
ops: ChatAbortOps;
sessionKey: string;
abortOrigin: AbortOrigin;
stopReason?: string;
+ requester: ChatAbortRequester;
}) {
+ const { matchedSessionRuns, authorizedRunIds } = resolveAuthorizedRunIdsForSession({
+ chatAbortControllers: params.context.chatAbortControllers,
+ sessionKey: params.sessionKey,
+ requester: params.requester,
+ });
+ if (authorizedRunIds.length === 0) {
+ return {
+ aborted: false,
+ runIds: [],
+ unauthorized: matchedSessionRuns > 0,
+ };
+ }
+ const authorizedRunIdSet = new Set(authorizedRunIds);
const snapshots = collectSessionAbortPartials({
chatAbortControllers: params.context.chatAbortControllers,
chatRunBuffers: params.context.chatRunBuffers,
- sessionKey: params.sessionKey,
+ runIds: authorizedRunIdSet,
abortOrigin: params.abortOrigin,
});
- const res = abortChatRunsForSessionKey(params.ops, {
- sessionKey: params.sessionKey,
- stopReason: params.stopReason,
- });
+ const runIds: string[] = [];
+ for (const runId of authorizedRunIds) {
+ const res = abortChatRunById(params.ops, {
+ runId,
+ sessionKey: params.sessionKey,
+ stopReason: params.stopReason,
+ });
+ if (res.aborted) {
+ runIds.push(runId);
+ }
+ }
+ const res = { aborted: runIds.length > 0, runIds, unauthorized: false };
if (res.aborted) {
persistAbortedPartials({
context: params.context,
@@ -802,7 +976,7 @@ export const chatHandlers: GatewayRequestHandlers = {
verboseLevel,
});
},
- "chat.abort": ({ params, respond, context }) => {
+ "chat.abort": ({ params, respond, context, client }) => {
if (!validateChatAbortParams(params)) {
respond(
false,
@@ -820,6 +994,7 @@ export const chatHandlers: GatewayRequestHandlers = {
};
const ops = createChatAbortOps(context);
+ const requester = resolveChatAbortRequester(client);
if (!runId) {
const res = abortChatRunsForSessionKeyWithPartials({
@@ -828,7 +1003,12 @@ export const chatHandlers: GatewayRequestHandlers = {
sessionKey: rawSessionKey,
abortOrigin: "rpc",
stopReason: "rpc",
+ requester,
});
+ if (res.unauthorized) {
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"));
+ return;
+ }
respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds });
return;
}
@@ -846,6 +1026,10 @@ export const chatHandlers: GatewayRequestHandlers = {
);
return;
}
+ if (!canRequesterAbortChatRun(active, requester)) {
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"));
+ return;
+ }
const partialText = context.chatRunBuffers.get(runId);
const res = abortChatRunById(ops, {
@@ -987,7 +1171,12 @@ export const chatHandlers: GatewayRequestHandlers = {
sessionKey: rawSessionKey,
abortOrigin: "stop-command",
stopReason: "stop",
+ requester: resolveChatAbortRequester(client),
});
+ if (res.unauthorized) {
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"));
+ return;
+ }
respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds });
return;
}
@@ -1017,6 +1206,8 @@ export const chatHandlers: GatewayRequestHandlers = {
sessionKey: rawSessionKey,
startedAtMs: now,
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
+ ownerConnId: normalizeOptionalText(client?.connId),
+ ownerDeviceId: normalizeOptionalText(client?.connect?.device?.id),
});
const ackPayload = {
runId: clientRunId,
diff --git a/src/gateway/server-methods/exec-approval.ts b/src/gateway/server-methods/exec-approval.ts
index 07dd8546c3f..81d479cbbd6 100644
--- a/src/gateway/server-methods/exec-approval.ts
+++ b/src/gateway/server-methods/exec-approval.ts
@@ -1,3 +1,4 @@
+import { sanitizeExecApprovalDisplayText } from "../../infra/exec-approval-command-display.js";
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
import {
DEFAULT_EXEC_APPROVAL_TIMEOUT_MS,
@@ -125,8 +126,11 @@ export function createExecApprovalHandlers(
return;
}
const request = {
- command: effectiveCommandText,
- commandPreview: host === "node" ? undefined : approvalContext.commandPreview,
+ command: sanitizeExecApprovalDisplayText(effectiveCommandText),
+ commandPreview:
+ host === "node" || !approvalContext.commandPreview
+ ? undefined
+ : sanitizeExecApprovalDisplayText(approvalContext.commandPreview),
commandArgv: host === "node" ? undefined : effectiveCommandArgv,
envKeys: systemRunBinding?.envKeys?.length ? systemRunBinding.envKeys : undefined,
systemRunBinding: systemRunBinding?.binding ?? null,
diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts
index 51da6927f5e..424511370cd 100644
--- a/src/gateway/server-methods/server-methods.test.ts
+++ b/src/gateway/server-methods/server-methods.test.ts
@@ -641,6 +641,34 @@ describe("exec approval handlers", () => {
);
});
+ it("sanitizes invisible Unicode format chars in approval display text without changing node bindings", async () => {
+ const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
+ await requestExecApproval({
+ handlers,
+ respond,
+ context,
+ params: {
+ timeoutMs: 10,
+ command: "bash safe\u200B.sh",
+ commandArgv: ["bash", "safe\u200B.sh"],
+ systemRunPlan: {
+ argv: ["bash", "safe\u200B.sh"],
+ cwd: "/real/cwd",
+ commandText: "bash safe\u200B.sh",
+ agentId: "main",
+ sessionKey: "agent:main:main",
+ },
+ },
+ });
+ const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
+ expect(requested).toBeTruthy();
+ const request = (requested?.payload as { request?: Record })?.request ?? {};
+ expect(request["command"]).toBe("bash safe\\u{200B}.sh");
+ expect((request["systemRunPlan"] as { commandText?: string }).commandText).toBe(
+ "bash safe\u200B.sh",
+ );
+ });
+
it("accepts resolve during broadcast", async () => {
const manager = new ExecApprovalManager();
const handlers = createExecApprovalHandlers(manager);
diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts
index 2e76e1a5de1..ca1e2c09402 100644
--- a/src/gateway/server.chat.gateway-server-chat-b.test.ts
+++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts
@@ -273,6 +273,37 @@ describe("gateway server chat", () => {
});
});
+ test("chat.history preserves usage and cost metadata for assistant messages", async () => {
+ await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
+ await connectOk(ws);
+
+ const sessionDir = await createSessionDir();
+ await writeMainSessionStore();
+
+ await writeMainSessionTranscript(sessionDir, [
+ JSON.stringify({
+ message: {
+ role: "assistant",
+ timestamp: Date.now(),
+ content: [{ type: "text", text: "hello" }],
+ usage: { input: 12, output: 5, totalTokens: 17 },
+ cost: { total: 0.0123 },
+ details: { debug: true },
+ },
+ }),
+ ]);
+
+ const messages = await fetchHistoryMessages(ws);
+ expect(messages).toHaveLength(1);
+ expect(messages[0]).toMatchObject({
+ role: "assistant",
+ usage: { input: 12, output: 5, totalTokens: 17 },
+ cost: { total: 0.0123 },
+ });
+ expect(messages[0]).not.toHaveProperty("details");
+ });
+ });
+
test("chat.history strips inline directives from displayed message text", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await connectOk(ws);
diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts
index 969c60c378c..e16777f4f2c 100644
--- a/src/gateway/session-utils.ts
+++ b/src/gateway/session-utils.ts
@@ -810,6 +810,7 @@ export function listSessionsFromStore(params: {
const model = resolvedModel.model ?? DEFAULT_MODEL;
return {
key,
+ spawnedBy: entry?.spawnedBy,
entry,
kind: classifySessionKey(key, entry),
label: entry?.label,
diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts
index 711a1997f22..80873b0000c 100644
--- a/src/gateway/session-utils.types.ts
+++ b/src/gateway/session-utils.types.ts
@@ -15,6 +15,7 @@ export type GatewaySessionsDefaults = {
export type GatewaySessionRow = {
key: string;
+ spawnedBy?: string;
kind: "direct" | "group" | "global" | "unknown";
label?: string;
displayName?: string;
diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts
index 2249c7f5c77..79e332f23ba 100644
--- a/src/gateway/sessions-patch.test.ts
+++ b/src/gateway/sessions-patch.test.ts
@@ -265,6 +265,19 @@ describe("gateway sessions patch", () => {
expect(entry.spawnedBy).toBe("agent:main:main");
});
+ test("sets spawnedWorkspaceDir for subagent sessions", async () => {
+ const entry = expectPatchOk(
+ await runPatch({
+ storeKey: "agent:main:subagent:child",
+ patch: {
+ key: "agent:main:subagent:child",
+ spawnedWorkspaceDir: "/tmp/subagent-workspace",
+ },
+ }),
+ );
+ expect(entry.spawnedWorkspaceDir).toBe("/tmp/subagent-workspace");
+ });
+
test("sets spawnDepth for ACP sessions", async () => {
const entry = expectPatchOk(
await runPatch({
@@ -282,6 +295,13 @@ describe("gateway sessions patch", () => {
expectPatchError(result, "spawnDepth is only supported");
});
+ test("rejects spawnedWorkspaceDir on non-subagent sessions", async () => {
+ const result = await runPatch({
+ patch: { key: MAIN_SESSION_KEY, spawnedWorkspaceDir: "/tmp/nope" },
+ });
+ expectPatchError(result, "spawnedWorkspaceDir is only supported");
+ });
+
test("normalizes exec/send/group patches", async () => {
const entry = expectPatchOk(
await runPatch({
diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts
index 1bf79ba4edf..66010e4745c 100644
--- a/src/gateway/sessions-patch.ts
+++ b/src/gateway/sessions-patch.ts
@@ -128,6 +128,27 @@ export async function applySessionsPatchToStore(params: {
}
}
+ if ("spawnedWorkspaceDir" in patch) {
+ const raw = patch.spawnedWorkspaceDir;
+ if (raw === null) {
+ if (existing?.spawnedWorkspaceDir) {
+ return invalid("spawnedWorkspaceDir cannot be cleared once set");
+ }
+ } else if (raw !== undefined) {
+ if (!supportsSpawnLineage(storeKey)) {
+ return invalid("spawnedWorkspaceDir is only supported for subagent:* or acp:* sessions");
+ }
+ const trimmed = String(raw).trim();
+ if (!trimmed) {
+ return invalid("invalid spawnedWorkspaceDir: empty");
+ }
+ if (existing?.spawnedWorkspaceDir && existing.spawnedWorkspaceDir !== trimmed) {
+ return invalid("spawnedWorkspaceDir cannot be changed once set");
+ }
+ next.spawnedWorkspaceDir = trimmed;
+ }
+ }
+
if ("spawnDepth" in patch) {
const raw = patch.spawnDepth;
if (raw === null) {
diff --git a/src/imessage/monitor/inbound-processing.test.ts b/src/imessage/monitor/inbound-processing.test.ts
index fab878a4cc7..b18012b9f1f 100644
--- a/src/imessage/monitor/inbound-processing.test.ts
+++ b/src/imessage/monitor/inbound-processing.test.ts
@@ -1,9 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
+import { sanitizeTerminalText } from "../../terminal/safe-text.js";
import {
describeIMessageEchoDropLog,
resolveIMessageInboundDecision,
} from "./inbound-processing.js";
+import { createSelfChatCache } from "./self-chat-cache.js";
describe("resolveIMessageInboundDecision echo detection", () => {
const cfg = {} as OpenClawConfig;
@@ -46,6 +48,324 @@ describe("resolveIMessageInboundDecision echo detection", () => {
}),
);
});
+
+ it("drops reflected self-chat duplicates after seeing the from-me copy", () => {
+ const selfChatCache = createSelfChatCache();
+ const createdAt = "2026-03-02T20:58:10.649Z";
+
+ expect(
+ resolveIMessageInboundDecision({
+ cfg,
+ accountId: "default",
+ message: {
+ id: 9641,
+ sender: "+15555550123",
+ text: "Do you want to report this issue?",
+ created_at: createdAt,
+ is_from_me: true,
+ is_group: false,
+ },
+ opts: undefined,
+ messageText: "Do you want to report this issue?",
+ bodyText: "Do you want to report this issue?",
+ allowFrom: [],
+ groupAllowFrom: [],
+ groupPolicy: "open",
+ dmPolicy: "open",
+ storeAllowFrom: [],
+ historyLimit: 0,
+ groupHistories: new Map(),
+ echoCache: undefined,
+ selfChatCache,
+ logVerbose: undefined,
+ }),
+ ).toEqual({ kind: "drop", reason: "from me" });
+
+ expect(
+ resolveIMessageInboundDecision({
+ cfg,
+ accountId: "default",
+ message: {
+ id: 9642,
+ sender: "+15555550123",
+ text: "Do you want to report this issue?",
+ created_at: createdAt,
+ is_from_me: false,
+ is_group: false,
+ },
+ opts: undefined,
+ messageText: "Do you want to report this issue?",
+ bodyText: "Do you want to report this issue?",
+ allowFrom: [],
+ groupAllowFrom: [],
+ groupPolicy: "open",
+ dmPolicy: "open",
+ storeAllowFrom: [],
+ historyLimit: 0,
+ groupHistories: new Map(),
+ echoCache: undefined,
+ selfChatCache,
+ logVerbose: undefined,
+ }),
+ ).toEqual({ kind: "drop", reason: "self-chat echo" });
+ });
+
+ it("does not drop same-text messages when created_at differs", () => {
+ const selfChatCache = createSelfChatCache();
+
+ resolveIMessageInboundDecision({
+ cfg,
+ accountId: "default",
+ message: {
+ id: 9641,
+ sender: "+15555550123",
+ text: "ok",
+ created_at: "2026-03-02T20:58:10.649Z",
+ is_from_me: true,
+ is_group: false,
+ },
+ opts: undefined,
+ messageText: "ok",
+ bodyText: "ok",
+ allowFrom: [],
+ groupAllowFrom: [],
+ groupPolicy: "open",
+ dmPolicy: "open",
+ storeAllowFrom: [],
+ historyLimit: 0,
+ groupHistories: new Map(),
+ echoCache: undefined,
+ selfChatCache,
+ logVerbose: undefined,
+ });
+
+ const decision = resolveIMessageInboundDecision({
+ cfg,
+ accountId: "default",
+ message: {
+ id: 9642,
+ sender: "+15555550123",
+ text: "ok",
+ created_at: "2026-03-02T20:58:11.649Z",
+ is_from_me: false,
+ is_group: false,
+ },
+ opts: undefined,
+ messageText: "ok",
+ bodyText: "ok",
+ allowFrom: [],
+ groupAllowFrom: [],
+ groupPolicy: "open",
+ dmPolicy: "open",
+ storeAllowFrom: [],
+ historyLimit: 0,
+ groupHistories: new Map(),
+ echoCache: undefined,
+ selfChatCache,
+ logVerbose: undefined,
+ });
+
+ expect(decision.kind).toBe("dispatch");
+ });
+
+ it("keeps self-chat cache scoped to configured group threads", () => {
+ const selfChatCache = createSelfChatCache();
+ const groupedCfg = {
+ channels: {
+ imessage: {
+ groups: {
+ "123": {},
+ "456": {},
+ },
+ },
+ },
+ } as OpenClawConfig;
+ const createdAt = "2026-03-02T20:58:10.649Z";
+
+ expect(
+ resolveIMessageInboundDecision({
+ cfg: groupedCfg,
+ accountId: "default",
+ message: {
+ id: 9701,
+ chat_id: 123,
+ sender: "+15555550123",
+ text: "same text",
+ created_at: createdAt,
+ is_from_me: true,
+ is_group: false,
+ },
+ opts: undefined,
+ messageText: "same text",
+ bodyText: "same text",
+ allowFrom: [],
+ groupAllowFrom: [],
+ groupPolicy: "open",
+ dmPolicy: "open",
+ storeAllowFrom: [],
+ historyLimit: 0,
+ groupHistories: new Map(),
+ echoCache: undefined,
+ selfChatCache,
+ logVerbose: undefined,
+ }),
+ ).toEqual({ kind: "drop", reason: "from me" });
+
+ const decision = resolveIMessageInboundDecision({
+ cfg: groupedCfg,
+ accountId: "default",
+ message: {
+ id: 9702,
+ chat_id: 456,
+ sender: "+15555550123",
+ text: "same text",
+ created_at: createdAt,
+ is_from_me: false,
+ is_group: false,
+ },
+ opts: undefined,
+ messageText: "same text",
+ bodyText: "same text",
+ allowFrom: [],
+ groupAllowFrom: [],
+ groupPolicy: "open",
+ dmPolicy: "open",
+ storeAllowFrom: [],
+ historyLimit: 0,
+ groupHistories: new Map(),
+ echoCache: undefined,
+ selfChatCache,
+ logVerbose: undefined,
+ });
+
+ expect(decision.kind).toBe("dispatch");
+ });
+
+ it("does not drop other participants in the same group thread", () => {
+ const selfChatCache = createSelfChatCache();
+ const createdAt = "2026-03-02T20:58:10.649Z";
+
+ expect(
+ resolveIMessageInboundDecision({
+ cfg,
+ accountId: "default",
+ message: {
+ id: 9751,
+ chat_id: 123,
+ sender: "+15555550123",
+ text: "same text",
+ created_at: createdAt,
+ is_from_me: true,
+ is_group: true,
+ },
+ opts: undefined,
+ messageText: "same text",
+ bodyText: "same text",
+ allowFrom: [],
+ groupAllowFrom: [],
+ groupPolicy: "open",
+ dmPolicy: "open",
+ storeAllowFrom: [],
+ historyLimit: 0,
+ groupHistories: new Map(),
+ echoCache: undefined,
+ selfChatCache,
+ logVerbose: undefined,
+ }),
+ ).toEqual({ kind: "drop", reason: "from me" });
+
+ const decision = resolveIMessageInboundDecision({
+ cfg,
+ accountId: "default",
+ message: {
+ id: 9752,
+ chat_id: 123,
+ sender: "+15555550999",
+ text: "same text",
+ created_at: createdAt,
+ is_from_me: false,
+ is_group: true,
+ },
+ opts: undefined,
+ messageText: "same text",
+ bodyText: "same text",
+ allowFrom: [],
+ groupAllowFrom: [],
+ groupPolicy: "open",
+ dmPolicy: "open",
+ storeAllowFrom: [],
+ historyLimit: 0,
+ groupHistories: new Map(),
+ echoCache: undefined,
+ selfChatCache,
+ logVerbose: undefined,
+ });
+
+ expect(decision.kind).toBe("dispatch");
+ });
+
+ it("sanitizes reflected duplicate previews before logging", () => {
+ const selfChatCache = createSelfChatCache();
+ const logVerbose = vi.fn();
+ const createdAt = "2026-03-02T20:58:10.649Z";
+ const bodyText = "line-1\nline-2\t\u001b[31mred";
+
+ resolveIMessageInboundDecision({
+ cfg,
+ accountId: "default",
+ message: {
+ id: 9801,
+ sender: "+15555550123",
+ text: bodyText,
+ created_at: createdAt,
+ is_from_me: true,
+ is_group: false,
+ },
+ opts: undefined,
+ messageText: bodyText,
+ bodyText,
+ allowFrom: [],
+ groupAllowFrom: [],
+ groupPolicy: "open",
+ dmPolicy: "open",
+ storeAllowFrom: [],
+ historyLimit: 0,
+ groupHistories: new Map(),
+ echoCache: undefined,
+ selfChatCache,
+ logVerbose,
+ });
+
+ resolveIMessageInboundDecision({
+ cfg,
+ accountId: "default",
+ message: {
+ id: 9802,
+ sender: "+15555550123",
+ text: bodyText,
+ created_at: createdAt,
+ is_from_me: false,
+ is_group: false,
+ },
+ opts: undefined,
+ messageText: bodyText,
+ bodyText,
+ allowFrom: [],
+ groupAllowFrom: [],
+ groupPolicy: "open",
+ dmPolicy: "open",
+ storeAllowFrom: [],
+ historyLimit: 0,
+ groupHistories: new Map(),
+ echoCache: undefined,
+ selfChatCache,
+ logVerbose,
+ });
+
+ expect(logVerbose).toHaveBeenCalledWith(
+ `imessage: dropping self-chat reflected duplicate: "${sanitizeTerminalText(bodyText)}"`,
+ );
+ });
});
describe("describeIMessageEchoDropLog", () => {
diff --git a/src/imessage/monitor/inbound-processing.ts b/src/imessage/monitor/inbound-processing.ts
index d042f1f1a0f..b3fc10c1e7b 100644
--- a/src/imessage/monitor/inbound-processing.ts
+++ b/src/imessage/monitor/inbound-processing.ts
@@ -24,6 +24,7 @@ import {
DM_GROUP_ACCESS_REASON,
resolveDmGroupAccessWithLists,
} from "../../security/dm-policy-shared.js";
+import { sanitizeTerminalText } from "../../terminal/safe-text.js";
import { truncateUtf16Safe } from "../../utils.js";
import {
formatIMessageChatTarget,
@@ -31,6 +32,7 @@ import {
normalizeIMessageHandle,
} from "../targets.js";
import { detectReflectedContent } from "./reflection-guard.js";
+import type { SelfChatCache } from "./self-chat-cache.js";
import type { MonitorIMessageOpts, IMessagePayload } from "./types.js";
type IMessageReplyContext = {
@@ -101,6 +103,7 @@ export function resolveIMessageInboundDecision(params: {
historyLimit: number;
groupHistories: Map;
echoCache?: { has: (scope: string, lookup: { text?: string; messageId?: string }) => boolean };
+ selfChatCache?: SelfChatCache;
logVerbose?: (msg: string) => void;
}): IMessageInboundDecision {
const senderRaw = params.message.sender ?? "";
@@ -109,13 +112,10 @@ export function resolveIMessageInboundDecision(params: {
return { kind: "drop", reason: "missing sender" };
}
const senderNormalized = normalizeIMessageHandle(sender);
- if (params.message.is_from_me) {
- return { kind: "drop", reason: "from me" };
- }
-
const chatId = params.message.chat_id ?? undefined;
const chatGuid = params.message.chat_guid ?? undefined;
const chatIdentifier = params.message.chat_identifier ?? undefined;
+ const createdAt = params.message.created_at ? Date.parse(params.message.created_at) : undefined;
const groupIdCandidate = chatId !== undefined ? String(chatId) : undefined;
const groupListPolicy = groupIdCandidate
@@ -138,6 +138,18 @@ export function resolveIMessageInboundDecision(params: {
groupIdCandidate && groupListPolicy.allowlistEnabled && groupListPolicy.groupConfig,
);
const isGroup = Boolean(params.message.is_group) || treatAsGroupByConfig;
+ const selfChatLookup = {
+ accountId: params.accountId,
+ isGroup,
+ chatId,
+ sender,
+ text: params.bodyText,
+ createdAt,
+ };
+ if (params.message.is_from_me) {
+ params.selfChatCache?.remember(selfChatLookup);
+ return { kind: "drop", reason: "from me" };
+ }
if (isGroup && !chatId) {
return { kind: "drop", reason: "group without chat_id" };
}
@@ -215,6 +227,17 @@ export function resolveIMessageInboundDecision(params: {
return { kind: "drop", reason: "empty body" };
}
+ if (
+ params.selfChatCache?.has({
+ ...selfChatLookup,
+ text: bodyText,
+ })
+ ) {
+ const preview = sanitizeTerminalText(truncateUtf16Safe(bodyText, 50));
+ params.logVerbose?.(`imessage: dropping self-chat reflected duplicate: "${preview}"`);
+ return { kind: "drop", reason: "self-chat echo" };
+ }
+
// Echo detection: check if the received message matches a recently sent message.
// Scope by conversation so same text in different chats is not conflated.
const inboundMessageId = params.message.id != null ? String(params.message.id) : undefined;
@@ -250,7 +273,6 @@ export function resolveIMessageInboundDecision(params: {
}
const replyContext = describeReplyContext(params.message);
- const createdAt = params.message.created_at ? Date.parse(params.message.created_at) : undefined;
const historyKey = isGroup
? String(chatId ?? chatGuid ?? chatIdentifier ?? "unknown")
: undefined;
diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts
index 1ea35b60d95..1324529cbff 100644
--- a/src/imessage/monitor/monitor-provider.ts
+++ b/src/imessage/monitor/monitor-provider.ts
@@ -53,6 +53,7 @@ import {
import { createLoopRateLimiter } from "./loop-rate-limiter.js";
import { parseIMessageNotification } from "./parse-notification.js";
import { normalizeAllowList, resolveRuntime } from "./runtime.js";
+import { createSelfChatCache } from "./self-chat-cache.js";
import type { IMessagePayload, MonitorIMessageOpts } from "./types.js";
/**
@@ -99,6 +100,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
);
const groupHistories = new Map();
const sentMessageCache = createSentMessageCache();
+ const selfChatCache = createSelfChatCache();
const loopRateLimiter = createLoopRateLimiter();
const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId);
const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom);
@@ -252,6 +254,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
historyLimit,
groupHistories,
echoCache: sentMessageCache,
+ selfChatCache,
logVerbose,
});
@@ -267,6 +270,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
// are normal and should not escalate.
const isLoopDrop =
decision.reason === "echo" ||
+ decision.reason === "self-chat echo" ||
decision.reason === "reflected assistant content" ||
decision.reason === "from me";
if (isLoopDrop) {
diff --git a/src/imessage/monitor/self-chat-cache.test.ts b/src/imessage/monitor/self-chat-cache.test.ts
new file mode 100644
index 00000000000..cf3a245ba30
--- /dev/null
+++ b/src/imessage/monitor/self-chat-cache.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it, vi } from "vitest";
+import { createSelfChatCache } from "./self-chat-cache.js";
+
+describe("createSelfChatCache", () => {
+ const directLookup = {
+ accountId: "default",
+ sender: "+15555550123",
+ isGroup: false,
+ } as const;
+
+ it("matches repeated lookups for the same scope, timestamp, and text", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ const cache = createSelfChatCache();
+ cache.remember({
+ ...directLookup,
+ text: " hello\r\nworld ",
+ createdAt: 123,
+ });
+
+ expect(
+ cache.has({
+ ...directLookup,
+ text: "hello\nworld",
+ createdAt: 123,
+ }),
+ ).toBe(true);
+ });
+
+ it("expires entries after the ttl window", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ const cache = createSelfChatCache();
+ cache.remember({ ...directLookup, text: "hello", createdAt: 123 });
+
+ vi.advanceTimersByTime(11_001);
+
+ expect(cache.has({ ...directLookup, text: "hello", createdAt: 123 })).toBe(false);
+ });
+
+ it("evicts older entries when the cache exceeds its cap", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ const cache = createSelfChatCache();
+ for (let i = 0; i < 513; i += 1) {
+ cache.remember({
+ ...directLookup,
+ text: `message-${i}`,
+ createdAt: i,
+ });
+ vi.advanceTimersByTime(1_001);
+ }
+
+ expect(cache.has({ ...directLookup, text: "message-0", createdAt: 0 })).toBe(false);
+ expect(cache.has({ ...directLookup, text: "message-512", createdAt: 512 })).toBe(true);
+ });
+
+ it("does not collide long texts that differ only in the middle", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ const cache = createSelfChatCache();
+ const prefix = "a".repeat(256);
+ const suffix = "b".repeat(256);
+ const longTextA = `${prefix}${"x".repeat(300)}${suffix}`;
+ const longTextB = `${prefix}${"y".repeat(300)}${suffix}`;
+
+ cache.remember({ ...directLookup, text: longTextA, createdAt: 123 });
+
+ expect(cache.has({ ...directLookup, text: longTextA, createdAt: 123 })).toBe(true);
+ expect(cache.has({ ...directLookup, text: longTextB, createdAt: 123 })).toBe(false);
+ });
+});
diff --git a/src/imessage/monitor/self-chat-cache.ts b/src/imessage/monitor/self-chat-cache.ts
new file mode 100644
index 00000000000..a2c4c31ccd9
--- /dev/null
+++ b/src/imessage/monitor/self-chat-cache.ts
@@ -0,0 +1,103 @@
+import { createHash } from "node:crypto";
+import { formatIMessageChatTarget } from "../targets.js";
+
+type SelfChatCacheKeyParts = {
+ accountId: string;
+ sender: string;
+ isGroup: boolean;
+ chatId?: number;
+};
+
+export type SelfChatLookup = SelfChatCacheKeyParts & {
+ text?: string;
+ createdAt?: number;
+};
+
+export type SelfChatCache = {
+ remember: (lookup: SelfChatLookup) => void;
+ has: (lookup: SelfChatLookup) => boolean;
+};
+
+const SELF_CHAT_TTL_MS = 10_000;
+const MAX_SELF_CHAT_CACHE_ENTRIES = 512;
+const CLEANUP_MIN_INTERVAL_MS = 1_000;
+
+function normalizeText(text: string | undefined): string | null {
+ if (!text) {
+ return null;
+ }
+ const normalized = text.replace(/\r\n?/g, "\n").trim();
+ return normalized ? normalized : null;
+}
+
+function isUsableTimestamp(createdAt: number | undefined): createdAt is number {
+ return typeof createdAt === "number" && Number.isFinite(createdAt);
+}
+
+function digestText(text: string): string {
+ return createHash("sha256").update(text).digest("hex");
+}
+
+function buildScope(parts: SelfChatCacheKeyParts): string {
+ if (!parts.isGroup) {
+ return `${parts.accountId}:imessage:${parts.sender}`;
+ }
+ const chatTarget = formatIMessageChatTarget(parts.chatId) || "chat_id:unknown";
+ return `${parts.accountId}:${chatTarget}:imessage:${parts.sender}`;
+}
+
+class DefaultSelfChatCache implements SelfChatCache {
+ private cache = new Map();
+ private lastCleanupAt = 0;
+
+ private buildKey(lookup: SelfChatLookup): string | null {
+ const text = normalizeText(lookup.text);
+ if (!text || !isUsableTimestamp(lookup.createdAt)) {
+ return null;
+ }
+ return `${buildScope(lookup)}:${lookup.createdAt}:${digestText(text)}`;
+ }
+
+ remember(lookup: SelfChatLookup): void {
+ const key = this.buildKey(lookup);
+ if (!key) {
+ return;
+ }
+ this.cache.set(key, Date.now());
+ this.maybeCleanup();
+ }
+
+ has(lookup: SelfChatLookup): boolean {
+ this.maybeCleanup();
+ const key = this.buildKey(lookup);
+ if (!key) {
+ return false;
+ }
+ const timestamp = this.cache.get(key);
+ return typeof timestamp === "number" && Date.now() - timestamp <= SELF_CHAT_TTL_MS;
+ }
+
+ private maybeCleanup(): void {
+ const now = Date.now();
+ if (now - this.lastCleanupAt < CLEANUP_MIN_INTERVAL_MS) {
+ return;
+ }
+ this.lastCleanupAt = now;
+ for (const [key, timestamp] of this.cache.entries()) {
+ if (now - timestamp > SELF_CHAT_TTL_MS) {
+ this.cache.delete(key);
+ }
+ }
+ while (this.cache.size > MAX_SELF_CHAT_CACHE_ENTRIES) {
+ const oldestKey = this.cache.keys().next().value;
+ if (typeof oldestKey !== "string") {
+ break;
+ }
+ this.cache.delete(oldestKey);
+ }
+ }
+}
+
+export function createSelfChatCache(): SelfChatCache {
+ return new DefaultSelfChatCache();
+}
diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts
index c76b44b323d..17f03df089a 100644
--- a/src/infra/device-pairing.test.ts
+++ b/src/infra/device-pairing.test.ts
@@ -1,16 +1,19 @@
-import { mkdtemp } from "node:fs/promises";
+import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import {
approveDevicePairing,
clearDevicePairing,
+ ensureDeviceToken,
getPairedDevice,
removePairedDevice,
requestDevicePairing,
rotateDeviceToken,
verifyDeviceToken,
+ type PairedDevice,
} from "./device-pairing.js";
+import { resolvePairingPaths } from "./pairing-files.js";
async function setupPairedOperatorDevice(baseDir: string, scopes: string[]) {
const request = await requestDevicePairing(
@@ -51,6 +54,43 @@ function requireToken(token: string | undefined): string {
return token;
}
+async function overwritePairedOperatorTokenScopes(baseDir: string, scopes: string[]) {
+ const { pairedPath } = resolvePairingPaths(baseDir, "devices");
+ const pairedByDeviceId = JSON.parse(await readFile(pairedPath, "utf8")) as Record<
+ string,
+ PairedDevice
+ >;
+ const device = pairedByDeviceId["device-1"];
+ expect(device?.tokens?.operator).toBeDefined();
+ if (!device?.tokens?.operator) {
+ throw new Error("expected paired operator token");
+ }
+ device.tokens.operator.scopes = scopes;
+ await writeFile(pairedPath, JSON.stringify(pairedByDeviceId, null, 2));
+}
+
+async function mutatePairedOperatorDevice(baseDir: string, mutate: (device: PairedDevice) => void) {
+ const { pairedPath } = resolvePairingPaths(baseDir, "devices");
+ const pairedByDeviceId = JSON.parse(await readFile(pairedPath, "utf8")) as Record<
+ string,
+ PairedDevice
+ >;
+ const device = pairedByDeviceId["device-1"];
+ expect(device).toBeDefined();
+ if (!device) {
+ throw new Error("expected paired operator device");
+ }
+ mutate(device);
+ await writeFile(pairedPath, JSON.stringify(pairedByDeviceId, null, 2));
+}
+
+async function clearPairedOperatorApprovalBaseline(baseDir: string) {
+ await mutatePairedOperatorDevice(baseDir, (device) => {
+ delete device.approvedScopes;
+ delete device.scopes;
+ });
+}
+
describe("device pairing tokens", () => {
test("reuses existing pending requests for the same device", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
@@ -180,6 +220,26 @@ describe("device pairing tokens", () => {
expect(after?.approvedScopes).toEqual(["operator.read"]);
});
+ test("rejects scope escalation when ensuring a token and leaves state unchanged", async () => {
+ const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
+ await setupPairedOperatorDevice(baseDir, ["operator.read"]);
+ const before = await getPairedDevice("device-1", baseDir);
+
+ const ensured = await ensureDeviceToken({
+ deviceId: "device-1",
+ role: "operator",
+ scopes: ["operator.admin"],
+ baseDir,
+ });
+ expect(ensured).toBeNull();
+
+ const after = await getPairedDevice("device-1", baseDir);
+ expect(after?.tokens?.operator?.token).toEqual(before?.tokens?.operator?.token);
+ expect(after?.tokens?.operator?.scopes).toEqual(["operator.read"]);
+ expect(after?.scopes).toEqual(["operator.read"]);
+ expect(after?.approvedScopes).toEqual(["operator.read"]);
+ });
+
test("verifies token and rejects mismatches", async () => {
const { baseDir, token } = await setupOperatorToken(["operator.read"]);
@@ -199,6 +259,32 @@ describe("device pairing tokens", () => {
expect(mismatch.reason).toBe("token-mismatch");
});
+ test("rejects persisted tokens whose scopes exceed the approved scope baseline", async () => {
+ const { baseDir, token } = await setupOperatorToken(["operator.read"]);
+ await overwritePairedOperatorTokenScopes(baseDir, ["operator.admin"]);
+
+ await expect(
+ verifyOperatorToken({
+ baseDir,
+ token,
+ scopes: ["operator.admin"],
+ }),
+ ).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
+ });
+
+ test("fails closed when the paired device approval baseline is missing during verification", async () => {
+ const { baseDir, token } = await setupOperatorToken(["operator.read"]);
+ await clearPairedOperatorApprovalBaseline(baseDir);
+
+ await expect(
+ verifyOperatorToken({
+ baseDir,
+ token,
+ scopes: ["operator.read"],
+ }),
+ ).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
+ });
+
test("accepts operator.read/operator.write requests with an operator.admin token scope", async () => {
const { baseDir, token } = await setupOperatorToken(["operator.admin"]);
@@ -217,6 +303,57 @@ describe("device pairing tokens", () => {
expect(writeOk.ok).toBe(true);
});
+ test("accepts custom operator scopes under an operator.admin approval baseline", async () => {
+ const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
+ await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
+
+ const rotated = await rotateDeviceToken({
+ deviceId: "device-1",
+ role: "operator",
+ scopes: ["operator.talk.secrets"],
+ baseDir,
+ });
+ expect(rotated?.scopes).toEqual(["operator.talk.secrets"]);
+
+ await expect(
+ verifyOperatorToken({
+ baseDir,
+ token: requireToken(rotated?.token),
+ scopes: ["operator.talk.secrets"],
+ }),
+ ).resolves.toEqual({ ok: true });
+ });
+
+ test("fails closed when the paired device approval baseline is missing during ensure", async () => {
+ const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
+ await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
+ await clearPairedOperatorApprovalBaseline(baseDir);
+
+ await expect(
+ ensureDeviceToken({
+ deviceId: "device-1",
+ role: "operator",
+ scopes: ["operator.admin"],
+ baseDir,
+ }),
+ ).resolves.toBeNull();
+ });
+
+ test("fails closed when the paired device approval baseline is missing during rotation", async () => {
+ const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
+ await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
+ await clearPairedOperatorApprovalBaseline(baseDir);
+
+ await expect(
+ rotateDeviceToken({
+ deviceId: "device-1",
+ role: "operator",
+ scopes: ["operator.admin"],
+ baseDir,
+ }),
+ ).resolves.toBeNull();
+ });
+
test("treats multibyte same-length token input as mismatch without throwing", async () => {
const { baseDir, token } = await setupOperatorToken(["operator.read"]);
const multibyteToken = "é".repeat(token.length);
diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts
index 591a9d70888..5bd2909a56e 100644
--- a/src/infra/device-pairing.ts
+++ b/src/infra/device-pairing.ts
@@ -181,44 +181,6 @@ function mergePendingDevicePairingRequest(
};
}
-function scopesAllow(requested: string[], allowed: string[]): boolean {
- if (requested.length === 0) {
- return true;
- }
- if (allowed.length === 0) {
- return false;
- }
- const allowedSet = new Set(allowed);
- return requested.every((scope) => allowedSet.has(scope));
-}
-
-const DEVICE_SCOPE_IMPLICATIONS: Readonly> = {
- "operator.admin": ["operator.read", "operator.write", "operator.approvals", "operator.pairing"],
- "operator.write": ["operator.read"],
-};
-
-function expandScopeImplications(scopes: string[]): string[] {
- const expanded = new Set(scopes);
- const queue = [...scopes];
- while (queue.length > 0) {
- const scope = queue.pop();
- if (!scope) {
- continue;
- }
- for (const impliedScope of DEVICE_SCOPE_IMPLICATIONS[scope] ?? []) {
- if (!expanded.has(impliedScope)) {
- expanded.add(impliedScope);
- queue.push(impliedScope);
- }
- }
- }
- return [...expanded];
-}
-
-function scopesAllowWithImplications(requested: string[], allowed: string[]): boolean {
- return scopesAllow(expandScopeImplications(requested), expandScopeImplications(allowed));
-}
-
function newToken() {
return generatePairingToken();
}
@@ -252,6 +214,29 @@ function buildDeviceAuthToken(params: {
};
}
+function resolveApprovedDeviceScopeBaseline(device: PairedDevice): string[] | null {
+ const baseline = device.approvedScopes ?? device.scopes;
+ if (!Array.isArray(baseline)) {
+ return null;
+ }
+ return normalizeDeviceAuthScopes(baseline);
+}
+
+function scopesWithinApprovedDeviceBaseline(params: {
+ role: string;
+ scopes: readonly string[];
+ approvedScopes: readonly string[] | null;
+}): boolean {
+ if (!params.approvedScopes) {
+ return false;
+ }
+ return roleScopesAllow({
+ role: params.role,
+ requestedScopes: params.scopes,
+ allowedScopes: params.approvedScopes,
+ });
+}
+
export async function listDevicePairing(baseDir?: string): Promise {
const state = await loadState(baseDir);
const pending = Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts);
@@ -494,6 +479,16 @@ export async function verifyDeviceToken(params: {
if (!verifyPairingToken(params.token, entry.token)) {
return { ok: false, reason: "token-mismatch" };
}
+ const approvedScopes = resolveApprovedDeviceScopeBaseline(device);
+ if (
+ !scopesWithinApprovedDeviceBaseline({
+ role,
+ scopes: entry.scopes,
+ approvedScopes,
+ })
+ ) {
+ return { ok: false, reason: "scope-mismatch" };
+ }
const requestedScopes = normalizeDeviceAuthScopes(params.scopes);
if (!roleScopesAllow({ role, requestedScopes, allowedScopes: entry.scopes })) {
return { ok: false, reason: "scope-mismatch" };
@@ -525,8 +520,26 @@ export async function ensureDeviceToken(params: {
return null;
}
const { device, role, tokens, existing } = context;
+ const approvedScopes = resolveApprovedDeviceScopeBaseline(device);
+ if (
+ !scopesWithinApprovedDeviceBaseline({
+ role,
+ scopes: requestedScopes,
+ approvedScopes,
+ })
+ ) {
+ return null;
+ }
if (existing && !existing.revokedAtMs) {
- if (roleScopesAllow({ role, requestedScopes, allowedScopes: existing.scopes })) {
+ const existingWithinApproved = scopesWithinApprovedDeviceBaseline({
+ role,
+ scopes: existing.scopes,
+ approvedScopes,
+ });
+ if (
+ existingWithinApproved &&
+ roleScopesAllow({ role, requestedScopes, allowedScopes: existing.scopes })
+ ) {
return existing;
}
}
@@ -589,10 +602,14 @@ export async function rotateDeviceToken(params: {
const requestedScopes = normalizeDeviceAuthScopes(
params.scopes ?? existing?.scopes ?? device.scopes,
);
- const approvedScopes = normalizeDeviceAuthScopes(
- device.approvedScopes ?? device.scopes ?? existing?.scopes,
- );
- if (!scopesAllowWithImplications(requestedScopes, approvedScopes)) {
+ const approvedScopes = resolveApprovedDeviceScopeBaseline(device);
+ if (
+ !scopesWithinApprovedDeviceBaseline({
+ role,
+ scopes: requestedScopes,
+ approvedScopes,
+ })
+ ) {
return null;
}
const now = Date.now();
diff --git a/src/infra/exec-allowlist-pattern.test.ts b/src/infra/exec-allowlist-pattern.test.ts
new file mode 100644
index 00000000000..1ac34112311
--- /dev/null
+++ b/src/infra/exec-allowlist-pattern.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { matchesExecAllowlistPattern } from "./exec-allowlist-pattern.js";
+
+describe("matchesExecAllowlistPattern", () => {
+ it("does not let ? cross path separators", () => {
+ expect(matchesExecAllowlistPattern("/tmp/a?b", "/tmp/a/b")).toBe(false);
+ expect(matchesExecAllowlistPattern("/tmp/a?b", "/tmp/acb")).toBe(true);
+ });
+
+ it("keeps ** matching across path separators", () => {
+ expect(matchesExecAllowlistPattern("/tmp/**/tool", "/tmp/a/b/tool")).toBe(true);
+ });
+
+ it.runIf(process.platform !== "win32")("preserves case sensitivity on POSIX", () => {
+ expect(matchesExecAllowlistPattern("/tmp/Allowed-Tool", "/tmp/allowed-tool")).toBe(false);
+ expect(matchesExecAllowlistPattern("/tmp/Allowed-Tool", "/tmp/Allowed-Tool")).toBe(true);
+ });
+
+ it.runIf(process.platform === "win32")("preserves case-insensitive matching on Windows", () => {
+ expect(matchesExecAllowlistPattern("C:/Tools/Allowed-Tool", "c:/tools/allowed-tool")).toBe(
+ true,
+ );
+ });
+});
diff --git a/src/infra/exec-allowlist-pattern.ts b/src/infra/exec-allowlist-pattern.ts
index df05a2ae1d9..96e93b6f797 100644
--- a/src/infra/exec-allowlist-pattern.ts
+++ b/src/infra/exec-allowlist-pattern.ts
@@ -9,7 +9,7 @@ function normalizeMatchTarget(value: string): string {
const stripped = value.replace(/^\\\\[?.]\\/, "");
return stripped.replace(/\\/g, "/").toLowerCase();
}
- return value.replace(/\\\\/g, "/").toLowerCase();
+ return value.replace(/\\\\/g, "/");
}
function tryRealpath(value: string): string | null {
@@ -25,7 +25,8 @@ function escapeRegExpLiteral(input: string): string {
}
function compileGlobRegex(pattern: string): RegExp {
- const cached = globRegexCache.get(pattern);
+ const cacheKey = `${process.platform}:${pattern}`;
+ const cached = globRegexCache.get(cacheKey);
if (cached) {
return cached;
}
@@ -46,7 +47,7 @@ function compileGlobRegex(pattern: string): RegExp {
continue;
}
if (ch === "?") {
- regex += ".";
+ regex += "[^/]";
i += 1;
continue;
}
@@ -55,11 +56,11 @@ function compileGlobRegex(pattern: string): RegExp {
}
regex += "$";
- const compiled = new RegExp(regex, "i");
+ const compiled = new RegExp(regex, process.platform === "win32" ? "i" : "");
if (globRegexCache.size >= GLOB_REGEX_CACHE_LIMIT) {
globRegexCache.clear();
}
- globRegexCache.set(pattern, compiled);
+ globRegexCache.set(cacheKey, compiled);
return compiled;
}
diff --git a/src/infra/exec-approval-command-display.ts b/src/infra/exec-approval-command-display.ts
index b5b00625ef2..9ab62e55669 100644
--- a/src/infra/exec-approval-command-display.ts
+++ b/src/infra/exec-approval-command-display.ts
@@ -1,8 +1,22 @@
import type { ExecApprovalRequestPayload } from "./exec-approvals.js";
+const UNICODE_FORMAT_CHAR_REGEX = /\p{Cf}/gu;
+
+function formatCodePointEscape(char: string): string {
+ return `\\u{${char.codePointAt(0)?.toString(16).toUpperCase() ?? "FFFD"}}`;
+}
+
+export function sanitizeExecApprovalDisplayText(commandText: string): string {
+ return commandText.replace(UNICODE_FORMAT_CHAR_REGEX, formatCodePointEscape);
+}
+
function normalizePreview(commandText: string, commandPreview?: string | null): string | null {
- const preview = commandPreview?.trim() ?? "";
- if (!preview || preview === commandText) {
+ const previewRaw = commandPreview?.trim() ?? "";
+ if (!previewRaw) {
+ return null;
+ }
+ const preview = sanitizeExecApprovalDisplayText(previewRaw);
+ if (preview === commandText) {
return null;
}
return preview;
@@ -12,17 +26,15 @@ export function resolveExecApprovalCommandDisplay(request: ExecApprovalRequestPa
commandText: string;
commandPreview: string | null;
} {
- if (request.host === "node" && request.systemRunPlan) {
- return {
- commandText: request.systemRunPlan.commandText,
- commandPreview: normalizePreview(
- request.systemRunPlan.commandText,
- request.systemRunPlan.commandPreview,
- ),
- };
- }
+ const commandTextSource =
+ request.command ||
+ (request.host === "node" && request.systemRunPlan ? request.systemRunPlan.commandText : "");
+ const commandText = sanitizeExecApprovalDisplayText(commandTextSource);
+ const previewSource =
+ request.commandPreview ??
+ (request.host === "node" ? (request.systemRunPlan?.commandPreview ?? null) : null);
return {
- commandText: request.command,
- commandPreview: normalizePreview(request.command, request.commandPreview),
+ commandText,
+ commandPreview: normalizePreview(commandText, previewSource),
};
}
diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts
index 8ae1b53cc57..ca4d81e012e 100644
--- a/src/infra/exec-approval-forwarder.test.ts
+++ b/src/infra/exec-approval-forwarder.test.ts
@@ -294,6 +294,24 @@ describe("exec approval forwarder", () => {
expect(text).toContain("Reply with: /approve allow-once|allow-always|deny");
});
+ it("renders invisible Unicode format chars as visible escapes", async () => {
+ vi.useFakeTimers();
+ const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
+
+ await expect(
+ forwarder.handleRequested({
+ ...baseRequest,
+ request: {
+ ...baseRequest.request,
+ command: "bash safe\u200B.sh",
+ },
+ }),
+ ).resolves.toBe(true);
+ await Promise.resolve();
+
+ expect(getFirstDeliveryText(deliver)).toContain("Command: `bash safe\\u{200B}.sh`");
+ });
+
it("formats complex commands as fenced code blocks", async () => {
vi.useFakeTimers();
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
diff --git a/src/infra/host-env-security-policy.json b/src/infra/host-env-security-policy.json
index 8b8f3cf3333..9e3ad27581e 100644
--- a/src/infra/host-env-security-policy.json
+++ b/src/infra/host-env-security-policy.json
@@ -11,6 +11,7 @@
"BASH_ENV",
"ENV",
"GIT_EXTERNAL_DIFF",
+ "GIT_EXEC_PATH",
"SHELL",
"SHELLOPTS",
"PS4",
diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts
index 4e7bcdb9ed9..08f1a3d65fb 100644
--- a/src/infra/host-env-security.test.ts
+++ b/src/infra/host-env-security.test.ts
@@ -18,6 +18,7 @@ describe("isDangerousHostEnvVarName", () => {
expect(isDangerousHostEnvVarName("bash_env")).toBe(true);
expect(isDangerousHostEnvVarName("SHELL")).toBe(true);
expect(isDangerousHostEnvVarName("GIT_EXTERNAL_DIFF")).toBe(true);
+ expect(isDangerousHostEnvVarName("git_exec_path")).toBe(true);
expect(isDangerousHostEnvVarName("SHELLOPTS")).toBe(true);
expect(isDangerousHostEnvVarName("ps4")).toBe(true);
expect(isDangerousHostEnvVarName("DYLD_INSERT_LIBRARIES")).toBe(true);
@@ -60,6 +61,7 @@ describe("sanitizeHostExecEnv", () => {
ZDOTDIR: "/tmp/evil-zdotdir",
BASH_ENV: "/tmp/pwn.sh",
GIT_SSH_COMMAND: "touch /tmp/pwned",
+ GIT_EXEC_PATH: "/tmp/git-exec-path",
EDITOR: "/tmp/editor",
NPM_CONFIG_USERCONFIG: "/tmp/npmrc",
GIT_CONFIG_GLOBAL: "/tmp/gitconfig",
@@ -73,6 +75,7 @@ describe("sanitizeHostExecEnv", () => {
expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE);
expect(env.BASH_ENV).toBeUndefined();
expect(env.GIT_SSH_COMMAND).toBeUndefined();
+ expect(env.GIT_EXEC_PATH).toBeUndefined();
expect(env.EDITOR).toBeUndefined();
expect(env.NPM_CONFIG_USERCONFIG).toBeUndefined();
expect(env.GIT_CONFIG_GLOBAL).toBeUndefined();
@@ -211,6 +214,65 @@ describe("shell wrapper exploit regression", () => {
});
describe("git env exploit regression", () => {
+ it("blocks inherited GIT_EXEC_PATH so git cannot execute helper payloads", async () => {
+ if (process.platform === "win32") {
+ return;
+ }
+ const gitPath = "/usr/bin/git";
+ if (!fs.existsSync(gitPath)) {
+ return;
+ }
+
+ const helperDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), `openclaw-git-exec-path-${process.pid}-${Date.now()}-`),
+ );
+ const helperPath = path.join(helperDir, "git-remote-https");
+ const marker = path.join(
+ os.tmpdir(),
+ `openclaw-git-exec-path-marker-${process.pid}-${Date.now()}`,
+ );
+ try {
+ try {
+ fs.unlinkSync(marker);
+ } catch {
+ // no-op
+ }
+ fs.writeFileSync(helperPath, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nexit 1\n`, "utf8");
+ fs.chmodSync(helperPath, 0o755);
+
+ const target = "https://127.0.0.1:1/does-not-matter";
+ const unsafeEnv = {
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
+ GIT_EXEC_PATH: helperDir,
+ GIT_TERMINAL_PROMPT: "0",
+ };
+
+ await new Promise((resolve) => {
+ const child = spawn(gitPath, ["ls-remote", target], { env: unsafeEnv, stdio: "ignore" });
+ child.once("error", () => resolve());
+ child.once("close", () => resolve());
+ });
+
+ expect(fs.existsSync(marker)).toBe(true);
+ fs.unlinkSync(marker);
+
+ const safeEnv = sanitizeHostExecEnv({
+ baseEnv: unsafeEnv,
+ });
+
+ await new Promise((resolve) => {
+ const child = spawn(gitPath, ["ls-remote", target], { env: safeEnv, stdio: "ignore" });
+ child.once("error", () => resolve());
+ child.once("close", () => resolve());
+ });
+
+ expect(fs.existsSync(marker)).toBe(false);
+ } finally {
+ fs.rmSync(helperDir, { recursive: true, force: true });
+ fs.rmSync(marker, { force: true });
+ }
+ });
+
it("blocks GIT_SSH_COMMAND override so git cannot execute helper payloads", async () => {
if (process.platform === "win32") {
return;
diff --git a/src/plugin-sdk/llm-task.ts b/src/plugin-sdk/llm-task.ts
index 164a28f0440..c69e82f36f7 100644
--- a/src/plugin-sdk/llm-task.ts
+++ b/src/plugin-sdk/llm-task.ts
@@ -2,4 +2,10 @@
// Keep this list additive and scoped to symbols used under extensions/llm-task.
export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
+export {
+ formatThinkingLevels,
+ formatXHighModelHint,
+ normalizeThinkLevel,
+ supportsXHighThinking,
+} from "../auto-reply/thinking.js";
export type { AnyAgentTool, OpenClawPluginApi } from "../plugins/types.js";
diff --git a/src/telegram/bot.fetch-abort.test.ts b/src/telegram/bot.fetch-abort.test.ts
index 471654686f7..0d9bd53643b 100644
--- a/src/telegram/bot.fetch-abort.test.ts
+++ b/src/telegram/bot.fetch-abort.test.ts
@@ -1,10 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { botCtorSpy } from "./bot.create-telegram-bot.test-harness.js";
import { createTelegramBot } from "./bot.js";
+import { getTelegramNetworkErrorOrigin } from "./network-errors.js";
describe("createTelegramBot fetch abort", () => {
it("aborts wrapped client fetch when fetchAbortSignal aborts", async () => {
- const originalFetch = globalThis.fetch;
const shutdown = new AbortController();
const fetchSpy = vi.fn(
(_input: RequestInfo | URL, init?: RequestInit) =>
@@ -13,22 +13,78 @@ describe("createTelegramBot fetch abort", () => {
signal.addEventListener("abort", () => resolve(signal), { once: true });
}),
);
- globalThis.fetch = fetchSpy as unknown as typeof fetch;
- try {
- botCtorSpy.mockClear();
- createTelegramBot({ token: "tok", fetchAbortSignal: shutdown.signal });
- const clientFetch = (botCtorSpy.mock.calls.at(-1)?.[1] as { client?: { fetch?: unknown } })
- ?.client?.fetch as (input: RequestInfo | URL, init?: RequestInit) => Promise;
- expect(clientFetch).toBeTypeOf("function");
+ botCtorSpy.mockClear();
+ createTelegramBot({
+ token: "tok",
+ fetchAbortSignal: shutdown.signal,
+ proxyFetch: fetchSpy as unknown as typeof fetch,
+ });
+ const clientFetch = (botCtorSpy.mock.calls.at(-1)?.[1] as { client?: { fetch?: unknown } })
+ ?.client?.fetch as (input: RequestInfo | URL, init?: RequestInit) => Promise;
+ expect(clientFetch).toBeTypeOf("function");
- const observedSignalPromise = clientFetch("https://example.test");
- shutdown.abort(new Error("shutdown"));
- const observedSignal = (await observedSignalPromise) as AbortSignal;
+ const observedSignalPromise = clientFetch("https://example.test");
+ shutdown.abort(new Error("shutdown"));
+ const observedSignal = (await observedSignalPromise) as AbortSignal;
- expect(observedSignal).toBeInstanceOf(AbortSignal);
- expect(observedSignal.aborted).toBe(true);
- } finally {
- globalThis.fetch = originalFetch;
- }
+ expect(observedSignal).toBeInstanceOf(AbortSignal);
+ expect(observedSignal.aborted).toBe(true);
+ });
+
+ it("tags wrapped Telegram fetch failures with the Bot API method", async () => {
+ const shutdown = new AbortController();
+ const fetchError = Object.assign(new TypeError("fetch failed"), {
+ cause: Object.assign(new Error("connect timeout"), {
+ code: "UND_ERR_CONNECT_TIMEOUT",
+ }),
+ });
+ const fetchSpy = vi.fn(async () => {
+ throw fetchError;
+ });
+ botCtorSpy.mockClear();
+ createTelegramBot({
+ token: "tok",
+ fetchAbortSignal: shutdown.signal,
+ proxyFetch: fetchSpy as unknown as typeof fetch,
+ });
+ const clientFetch = (botCtorSpy.mock.calls.at(-1)?.[1] as { client?: { fetch?: unknown } })
+ ?.client?.fetch as (input: RequestInfo | URL, init?: RequestInit) => Promise;
+ expect(clientFetch).toBeTypeOf("function");
+
+ await expect(clientFetch("https://api.telegram.org/bot123456:ABC/getUpdates")).rejects.toBe(
+ fetchError,
+ );
+ expect(getTelegramNetworkErrorOrigin(fetchError)).toEqual({
+ method: "getupdates",
+ url: "https://api.telegram.org/bot123456:ABC/getUpdates",
+ });
+ });
+
+ it("preserves the original fetch error when tagging cannot attach metadata", async () => {
+ const shutdown = new AbortController();
+ const frozenError = Object.freeze(
+ Object.assign(new TypeError("fetch failed"), {
+ cause: Object.assign(new Error("connect timeout"), {
+ code: "UND_ERR_CONNECT_TIMEOUT",
+ }),
+ }),
+ );
+ const fetchSpy = vi.fn(async () => {
+ throw frozenError;
+ });
+ botCtorSpy.mockClear();
+ createTelegramBot({
+ token: "tok",
+ fetchAbortSignal: shutdown.signal,
+ proxyFetch: fetchSpy as unknown as typeof fetch,
+ });
+ const clientFetch = (botCtorSpy.mock.calls.at(-1)?.[1] as { client?: { fetch?: unknown } })
+ ?.client?.fetch as (input: RequestInfo | URL, init?: RequestInit) => Promise;
+ expect(clientFetch).toBeTypeOf("function");
+
+ await expect(clientFetch("https://api.telegram.org/bot123456:ABC/getUpdates")).rejects.toBe(
+ frozenError,
+ );
+ expect(getTelegramNetworkErrorOrigin(frozenError)).toBeNull();
});
});
diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts
index 48d0c745b42..b0c288efcea 100644
--- a/src/telegram/bot.ts
+++ b/src/telegram/bot.ts
@@ -39,6 +39,7 @@ import {
} from "./bot-updates.js";
import { buildTelegramGroupPeerId, resolveTelegramStreamMode } from "./bot/helpers.js";
import { resolveTelegramFetch } from "./fetch.js";
+import { tagTelegramNetworkError } from "./network-errors.js";
import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js";
import { getTelegramSequentialKey } from "./sequential-key.js";
import { createTelegramThreadBindingManager } from "./thread-bindings.js";
@@ -68,6 +69,34 @@ export type TelegramBotOptions = {
export { getTelegramSequentialKey };
+function readRequestUrl(input: RequestInfo | URL): string | null {
+ if (typeof input === "string") {
+ return input;
+ }
+ if (input instanceof URL) {
+ return input.toString();
+ }
+ if (typeof input === "object" && input !== null && "url" in input) {
+ const url = (input as { url?: unknown }).url;
+ return typeof url === "string" ? url : null;
+ }
+ return null;
+}
+
+function extractTelegramApiMethod(input: RequestInfo | URL): string | null {
+ const url = readRequestUrl(input);
+ if (!url) {
+ return null;
+ }
+ try {
+ const pathname = new URL(url).pathname;
+ const segments = pathname.split("/").filter(Boolean);
+ return segments.length > 0 ? (segments.at(-1) ?? null) : null;
+ } catch {
+ return null;
+ }
+}
+
export function createTelegramBot(opts: TelegramBotOptions) {
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
const cfg = opts.config ?? loadConfig();
@@ -147,6 +176,23 @@ export function createTelegramBot(opts: TelegramBotOptions) {
});
}) as unknown as NonNullable;
}
+ if (finalFetch) {
+ const baseFetch = finalFetch;
+ finalFetch = ((input: RequestInfo | URL, init?: RequestInit) => {
+ return Promise.resolve(baseFetch(input, init)).catch((err: unknown) => {
+ try {
+ tagTelegramNetworkError(err, {
+ method: extractTelegramApiMethod(input),
+ url: readRequestUrl(input),
+ });
+ } catch {
+ // Tagging is best-effort; preserve the original fetch failure if the
+ // error object cannot accept extra metadata.
+ }
+ throw err;
+ });
+ }) as unknown as NonNullable;
+ }
const timeoutSeconds =
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts
index f8423866fd0..d7ebef73373 100644
--- a/src/telegram/monitor.test.ts
+++ b/src/telegram/monitor.test.ts
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { monitorTelegramProvider } from "./monitor.js";
+import { tagTelegramNetworkError } from "./network-errors.js";
type MockCtx = {
message: {
@@ -102,6 +103,15 @@ function makeRecoverableFetchError() {
});
}
+function makeTaggedPollingFetchError() {
+ const err = makeRecoverableFetchError();
+ tagTelegramNetworkError(err, {
+ method: "getUpdates",
+ url: "https://api.telegram.org/bot123456:ABC/getUpdates",
+ });
+ return err;
+}
+
const createAbortTask = (
abort: AbortController,
beforeAbort?: () => void,
@@ -453,7 +463,7 @@ describe("monitorTelegramProvider (grammY)", () => {
const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(1));
- expect(emitUnhandledRejection(new TypeError("fetch failed"))).toBe(true);
+ expect(emitUnhandledRejection(makeTaggedPollingFetchError())).toBe(true);
await monitor;
expect(stop.mock.calls.length).toBeGreaterThanOrEqual(1);
@@ -496,13 +506,54 @@ describe("monitorTelegramProvider (grammY)", () => {
expect(firstSignal).toBeInstanceOf(AbortSignal);
expect((firstSignal as AbortSignal).aborted).toBe(false);
- expect(emitUnhandledRejection(new TypeError("fetch failed"))).toBe(true);
+ expect(emitUnhandledRejection(makeTaggedPollingFetchError())).toBe(true);
await monitor;
expect((firstSignal as AbortSignal).aborted).toBe(true);
expect(stop).toHaveBeenCalled();
});
+ it("ignores unrelated process-level network errors while telegram polling is active", async () => {
+ const abort = new AbortController();
+ let running = true;
+ let releaseTask: (() => void) | undefined;
+ const stop = vi.fn(async () => {
+ running = false;
+ releaseTask?.();
+ });
+
+ runSpy.mockImplementationOnce(() =>
+ makeRunnerStub({
+ task: () =>
+ new Promise((resolve) => {
+ releaseTask = resolve;
+ }),
+ stop,
+ isRunning: () => running,
+ }),
+ );
+
+ const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
+ await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(1));
+
+ const slackDnsError = Object.assign(
+ new Error("A request error occurred: getaddrinfo ENOTFOUND slack.com"),
+ {
+ code: "ENOTFOUND",
+ hostname: "slack.com",
+ },
+ );
+ expect(emitUnhandledRejection(slackDnsError)).toBe(false);
+
+ abort.abort();
+ await monitor;
+
+ expect(stop).toHaveBeenCalledTimes(1);
+ expect(computeBackoff).not.toHaveBeenCalled();
+ expect(sleepWithAbort).not.toHaveBeenCalled();
+ expect(runSpy).toHaveBeenCalledTimes(1);
+ });
+
it("passes configured webhookHost to webhook listener", async () => {
await monitorTelegramProvider({
token: "tok",
diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts
index 7131876e6f1..f7704f62dea 100644
--- a/src/telegram/monitor.ts
+++ b/src/telegram/monitor.ts
@@ -9,7 +9,10 @@ import type { RuntimeEnv } from "../runtime.js";
import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js";
-import { isRecoverableTelegramNetworkError } from "./network-errors.js";
+import {
+ isRecoverableTelegramNetworkError,
+ isTelegramPollingNetworkError,
+} from "./network-errors.js";
import { TelegramPollingSession } from "./polling-session.js";
import { makeProxyFetch } from "./proxy.js";
import { readTelegramUpdateOffset, writeTelegramUpdateOffset } from "./update-offset-store.js";
@@ -78,13 +81,14 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
const unregisterHandler = registerUnhandledRejectionHandler((err) => {
const isNetworkError = isRecoverableTelegramNetworkError(err, { context: "polling" });
- if (isGrammyHttpError(err) && isNetworkError) {
+ const isTelegramPollingError = isTelegramPollingNetworkError(err);
+ if (isGrammyHttpError(err) && isNetworkError && isTelegramPollingError) {
log(`[telegram] Suppressed network error: ${formatErrorMessage(err)}`);
return true;
}
const activeRunner = pollingSession?.activeRunner;
- if (isNetworkError && activeRunner && activeRunner.isRunning()) {
+ if (isNetworkError && isTelegramPollingError && activeRunner && activeRunner.isRunning()) {
pollingSession?.markForceRestarted();
pollingSession?.abortActiveFetch();
void activeRunner.stop().catch(() => {});
diff --git a/src/telegram/network-errors.test.ts b/src/telegram/network-errors.test.ts
index 6624b8f63a0..56106a292b8 100644
--- a/src/telegram/network-errors.test.ts
+++ b/src/telegram/network-errors.test.ts
@@ -1,12 +1,37 @@
import { describe, expect, it } from "vitest";
import {
+ getTelegramNetworkErrorOrigin,
isRecoverableTelegramNetworkError,
isSafeToRetrySendError,
isTelegramClientRejection,
+ isTelegramPollingNetworkError,
isTelegramServerError,
+ tagTelegramNetworkError,
} from "./network-errors.js";
describe("isRecoverableTelegramNetworkError", () => {
+ it("tracks Telegram polling origin separately from generic network matching", () => {
+ const slackDnsError = Object.assign(
+ new Error("A request error occurred: getaddrinfo ENOTFOUND slack.com"),
+ {
+ code: "ENOTFOUND",
+ hostname: "slack.com",
+ },
+ );
+ expect(isRecoverableTelegramNetworkError(slackDnsError)).toBe(true);
+ expect(isTelegramPollingNetworkError(slackDnsError)).toBe(false);
+
+ tagTelegramNetworkError(slackDnsError, {
+ method: "getUpdates",
+ url: "https://api.telegram.org/bot123456:ABC/getUpdates",
+ });
+ expect(getTelegramNetworkErrorOrigin(slackDnsError)).toEqual({
+ method: "getupdates",
+ url: "https://api.telegram.org/bot123456:ABC/getUpdates",
+ });
+ expect(isTelegramPollingNetworkError(slackDnsError)).toBe(true);
+ });
+
it("detects recoverable error codes", () => {
const err = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" });
expect(isRecoverableTelegramNetworkError(err)).toBe(true);
diff --git a/src/telegram/network-errors.ts b/src/telegram/network-errors.ts
index 66da37c4dd4..08e5d2dc2c0 100644
--- a/src/telegram/network-errors.ts
+++ b/src/telegram/network-errors.ts
@@ -5,6 +5,8 @@ import {
readErrorName,
} from "../infra/errors.js";
+const TELEGRAM_NETWORK_ORIGIN = Symbol("openclaw.telegram.network-origin");
+
const RECOVERABLE_ERROR_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",
@@ -101,6 +103,51 @@ function getErrorCode(err: unknown): string | undefined {
}
export type TelegramNetworkErrorContext = "polling" | "send" | "webhook" | "unknown";
+export type TelegramNetworkErrorOrigin = {
+ method?: string | null;
+ url?: string | null;
+};
+
+function normalizeTelegramNetworkMethod(method?: string | null): string | null {
+ const trimmed = method?.trim();
+ if (!trimmed) {
+ return null;
+ }
+ return trimmed.toLowerCase();
+}
+
+export function tagTelegramNetworkError(err: unknown, origin: TelegramNetworkErrorOrigin): void {
+ if (!err || typeof err !== "object") {
+ return;
+ }
+ Object.defineProperty(err, TELEGRAM_NETWORK_ORIGIN, {
+ value: {
+ method: normalizeTelegramNetworkMethod(origin.method),
+ url: typeof origin.url === "string" && origin.url.trim() ? origin.url : null,
+ } satisfies TelegramNetworkErrorOrigin,
+ configurable: true,
+ });
+}
+
+export function getTelegramNetworkErrorOrigin(err: unknown): TelegramNetworkErrorOrigin | null {
+ for (const candidate of collectTelegramErrorCandidates(err)) {
+ if (!candidate || typeof candidate !== "object") {
+ continue;
+ }
+ const origin = (candidate as Record)[TELEGRAM_NETWORK_ORIGIN];
+ if (!origin || typeof origin !== "object") {
+ continue;
+ }
+ const method = "method" in origin && typeof origin.method === "string" ? origin.method : null;
+ const url = "url" in origin && typeof origin.url === "string" ? origin.url : null;
+ return { method, url };
+ }
+ return null;
+}
+
+export function isTelegramPollingNetworkError(err: unknown): boolean {
+ return getTelegramNetworkErrorOrigin(err)?.method === "getupdates";
+}
/**
* Returns true if the error is safe to retry for a non-idempotent Telegram send operation
diff --git a/src/tui/components/chat-log.test.ts b/src/tui/components/chat-log.test.ts
index 02607568b1d..b81740a2e8c 100644
--- a/src/tui/components/chat-log.test.ts
+++ b/src/tui/components/chat-log.test.ts
@@ -29,6 +29,17 @@ describe("ChatLog", () => {
expect(rendered).toContain("recreated");
});
+ it("does not append duplicate assistant components when a run is started twice", () => {
+ const chatLog = new ChatLog(40);
+ chatLog.startAssistant("first", "run-dup");
+ chatLog.startAssistant("second", "run-dup");
+
+ const rendered = chatLog.render(120).join("\n");
+ expect(rendered).toContain("second");
+ expect(rendered).not.toContain("first");
+ expect(chatLog.children.length).toBe(1);
+ });
+
it("drops stale tool references when old components are pruned", () => {
const chatLog = new ChatLog(20);
chatLog.startTool("tool-1", "read_file", { path: "a.txt" });
diff --git a/src/tui/components/chat-log.ts b/src/tui/components/chat-log.ts
index 4ddf1d5b1de..76ac7d93654 100644
--- a/src/tui/components/chat-log.ts
+++ b/src/tui/components/chat-log.ts
@@ -65,8 +65,14 @@ export class ChatLog extends Container {
}
startAssistant(text: string, runId?: string) {
+ const effectiveRunId = this.resolveRunId(runId);
+ const existing = this.streamingRuns.get(effectiveRunId);
+ if (existing) {
+ existing.setText(text);
+ return existing;
+ }
const component = new AssistantMessageComponent(text);
- this.streamingRuns.set(this.resolveRunId(runId), component);
+ this.streamingRuns.set(effectiveRunId, component);
this.append(component);
return component;
}
diff --git a/src/utils.test.ts b/src/utils.test.ts
index ec9a0f4a1a1..0f4823c4019 100644
--- a/src/utils.test.ts
+++ b/src/utils.test.ts
@@ -8,7 +8,6 @@ import {
ensureDir,
jidToE164,
normalizeE164,
- normalizePath,
resolveConfigDir,
resolveHomeDir,
resolveJidToE164,
@@ -17,7 +16,6 @@ import {
shortenHomePath,
sleep,
toWhatsappJid,
- withWhatsAppPrefix,
} from "./utils.js";
function withTempDirSync(prefix: string, run: (dir: string) => T): T {
@@ -29,26 +27,6 @@ function withTempDirSync(prefix: string, run: (dir: string) => T): T {
}
}
-describe("normalizePath", () => {
- it("adds leading slash when missing", () => {
- expect(normalizePath("foo")).toBe("/foo");
- });
-
- it("keeps existing slash", () => {
- expect(normalizePath("/bar")).toBe("/bar");
- });
-});
-
-describe("withWhatsAppPrefix", () => {
- it("adds whatsapp prefix", () => {
- expect(withWhatsAppPrefix("+1555")).toBe("whatsapp:+1555");
- });
-
- it("leaves prefixed intact", () => {
- expect(withWhatsAppPrefix("whatsapp:+1555")).toBe("whatsapp:+1555");
- });
-});
-
describe("ensureDir", () => {
it("creates nested directory", async () => {
await withTempDirSync("openclaw-test-", async (tmp) => {
diff --git a/src/utils.ts b/src/utils.ts
index 55efabb1ba2..cb044d05b69 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -73,17 +73,6 @@ export function assertWebChannel(input: string): asserts input is WebChannel {
}
}
-export function normalizePath(p: string): string {
- if (!p.startsWith("/")) {
- return `/${p}`;
- }
- return p;
-}
-
-export function withWhatsAppPrefix(number: string): string {
- return number.startsWith("whatsapp:") ? number : `whatsapp:${number}`;
-}
-
export function normalizeE164(number: string): string {
const withoutPrefix = number.replace(/^whatsapp:/, "").trim();
const digits = withoutPrefix.replace(/[^\d+]/g, "");
diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts
index c4a83017c19..cd273965829 100644
--- a/ui/src/i18n/locales/en.ts
+++ b/ui/src/i18n/locales/en.ts
@@ -12,7 +12,9 @@ export const en: TranslationMap = {
disabled: "Disabled",
na: "n/a",
docs: "Docs",
+ theme: "Theme",
resources: "Resources",
+ search: "Search",
},
nav: {
chat: "Chat",
@@ -21,6 +23,7 @@ export const en: TranslationMap = {
settings: "Settings",
expand: "Expand sidebar",
collapse: "Collapse sidebar",
+ resize: "Resize sidebar",
},
tabs: {
agents: "Agents",
@@ -34,23 +37,33 @@ export const en: TranslationMap = {
nodes: "Nodes",
chat: "Chat",
config: "Config",
+ communications: "Communications",
+ appearance: "Appearance",
+ automation: "Automation",
+ infrastructure: "Infrastructure",
+ aiAgents: "AI & Agents",
debug: "Debug",
logs: "Logs",
},
subtitles: {
- agents: "Manage agent workspaces, tools, and identities.",
- overview: "Gateway status, entry points, and a fast health read.",
- channels: "Manage channels and settings.",
- instances: "Presence beacons from connected clients and nodes.",
- sessions: "Inspect active sessions and adjust per-session defaults.",
- usage: "Monitor API usage and costs.",
- cron: "Schedule wakeups and recurring agent runs.",
- skills: "Manage skill availability and API key injection.",
- nodes: "Paired devices, capabilities, and command exposure.",
- chat: "Direct gateway chat session for quick interventions.",
- config: "Edit ~/.openclaw/openclaw.json safely.",
- debug: "Gateway snapshots, events, and manual RPC calls.",
- logs: "Live tail of the gateway file logs.",
+ agents: "Workspaces, tools, identities.",
+ overview: "Status, entry points, health.",
+ channels: "Channels and settings.",
+ instances: "Connected clients and nodes.",
+ sessions: "Active sessions and defaults.",
+ usage: "API usage and costs.",
+ cron: "Wakeups and recurring runs.",
+ skills: "Skills and API keys.",
+ nodes: "Paired devices and commands.",
+ chat: "Gateway chat for quick interventions.",
+ config: "Edit openclaw.json.",
+ communications: "Channels, messages, and audio settings.",
+ appearance: "Theme, UI, and setup wizard settings.",
+ automation: "Commands, hooks, cron, and plugins.",
+ infrastructure: "Gateway, web, browser, and media settings.",
+ aiAgents: "Agents, models, skills, tools, memory, session.",
+ debug: "Snapshots, events, RPC.",
+ logs: "Live gateway logs.",
},
overview: {
access: {
@@ -105,6 +118,47 @@ export const en: TranslationMap = {
hint: "This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open {url} on the gateway host.",
stayHttp: "If you must stay on HTTP, set {config} (token-only).",
},
+ connection: {
+ title: "How to connect",
+ step1: "Start the gateway on your host machine:",
+ step2: "Get a tokenized dashboard URL:",
+ step3: "Paste the WebSocket URL and token above, or open the tokenized URL directly.",
+ step4: "Or generate a reusable token:",
+ docsHint: "For remote access, Tailscale Serve is recommended. ",
+ docsLink: "Read the docs →",
+ },
+ cards: {
+ cost: "Cost",
+ skills: "Skills",
+ recentSessions: "Recent Sessions",
+ },
+ attention: {
+ title: "Attention",
+ },
+ eventLog: {
+ title: "Event Log",
+ },
+ logTail: {
+ title: "Gateway Logs",
+ },
+ quickActions: {
+ newSession: "New Session",
+ automation: "Automation",
+ refreshAll: "Refresh All",
+ terminal: "Terminal",
+ },
+ streamMode: {
+ active: "Stream mode — values redacted",
+ disable: "Disable",
+ },
+ palette: {
+ placeholder: "Type a command…",
+ noResults: "No results",
+ },
+ },
+ login: {
+ subtitle: "Gateway Dashboard",
+ passwordPlaceholder: "optional", // pragma: allowlist secret
},
chat: {
disconnected: "Disconnected from gateway.",
diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts
index d763ca04217..f656793e78b 100644
--- a/ui/src/i18n/locales/pt-BR.ts
+++ b/ui/src/i18n/locales/pt-BR.ts
@@ -12,7 +12,9 @@ export const pt_BR: TranslationMap = {
disabled: "Desativado",
na: "n/a",
docs: "Docs",
+ theme: "Tema",
resources: "Recursos",
+ search: "Pesquisar",
},
nav: {
chat: "Chat",
@@ -21,6 +23,7 @@ export const pt_BR: TranslationMap = {
settings: "Configurações",
expand: "Expandir barra lateral",
collapse: "Recolher barra lateral",
+ resize: "Redimensionar barra lateral",
},
tabs: {
agents: "Agentes",
@@ -34,23 +37,33 @@ export const pt_BR: TranslationMap = {
nodes: "Nós",
chat: "Chat",
config: "Config",
+ communications: "Comunicações",
+ appearance: "Aparência e Configuração",
+ automation: "Automação",
+ infrastructure: "Infraestrutura",
+ aiAgents: "IA e Agentes",
debug: "Debug",
logs: "Logs",
},
subtitles: {
- agents: "Gerenciar espaços de trabalho, ferramentas e identidades de agentes.",
- overview: "Status do gateway, pontos de entrada e leitura rápida de saúde.",
- channels: "Gerenciar canais e configurações.",
- instances: "Beacons de presença de clientes e nós conectados.",
- sessions: "Inspecionar sessões ativas e ajustar padrões por sessão.",
- usage: "Monitorar uso e custos da API.",
- cron: "Agendar despertares e execuções recorrentes de agentes.",
- skills: "Gerenciar disponibilidade de habilidades e injeção de chaves de API.",
- nodes: "Dispositivos pareados, capacidades e exposição de comandos.",
- chat: "Sessão de chat direta com o gateway para intervenções rápidas.",
- config: "Editar ~/.openclaw/openclaw.json com segurança.",
- debug: "Snapshots do gateway, eventos e chamadas RPC manuais.",
- logs: "Acompanhamento ao vivo dos logs de arquivo do gateway.",
+ agents: "Espaços, ferramentas, identidades.",
+ overview: "Status, entrada, saúde.",
+ channels: "Canais e configurações.",
+ instances: "Clientes e nós conectados.",
+ sessions: "Sessões ativas e padrões.",
+ usage: "Uso e custos da API.",
+ cron: "Despertares e execuções.",
+ skills: "Habilidades e chaves API.",
+ nodes: "Dispositivos e comandos.",
+ chat: "Chat do gateway para intervenções rápidas.",
+ config: "Editar openclaw.json.",
+ communications: "Configurações de canais, mensagens e áudio.",
+ appearance: "Configurações de tema, UI e assistente de configuração.",
+ automation: "Configurações de comandos, hooks, cron e plugins.",
+ infrastructure: "Configurações de gateway, web, browser e mídia.",
+ aiAgents: "Configurações de agentes, modelos, habilidades, ferramentas, memória e sessão.",
+ debug: "Snapshots, eventos, RPC.",
+ logs: "Logs ao vivo do gateway.",
},
overview: {
access: {
@@ -107,6 +120,47 @@ export const pt_BR: TranslationMap = {
hint: "Esta página é HTTP, então o navegador bloqueia a identidade do dispositivo. Use HTTPS (Tailscale Serve) ou abra {url} no host do gateway.",
stayHttp: "Se você precisar permanecer em HTTP, defina {config} (apenas token).",
},
+ connection: {
+ title: "Como conectar",
+ step1: "Inicie o gateway na sua máquina host:",
+ step2: "Obtenha uma URL do painel com token:",
+ step3: "Cole a URL do WebSocket e o token acima, ou abra a URL com token diretamente.",
+ step4: "Ou gere um token reutilizável:",
+ docsHint: "Para acesso remoto, recomendamos o Tailscale Serve. ",
+ docsLink: "Leia a documentação →",
+ },
+ cards: {
+ cost: "Custo",
+ skills: "Habilidades",
+ recentSessions: "Sessões Recentes",
+ },
+ attention: {
+ title: "Atenção",
+ },
+ eventLog: {
+ title: "Log de Eventos",
+ },
+ logTail: {
+ title: "Logs do Gateway",
+ },
+ quickActions: {
+ newSession: "Nova Sessão",
+ automation: "Automação",
+ refreshAll: "Atualizar Tudo",
+ terminal: "Terminal",
+ },
+ streamMode: {
+ active: "Modo stream — valores ocultos",
+ disable: "Desativar",
+ },
+ palette: {
+ placeholder: "Digite um comando…",
+ noResults: "Sem resultados",
+ },
+ },
+ login: {
+ subtitle: "Painel do Gateway",
+ passwordPlaceholder: "opcional", // pragma: allowlist secret
},
chat: {
disconnected: "Desconectado do gateway.",
diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts
index 2cf8ca35ec2..ef3cd77ae17 100644
--- a/ui/src/i18n/locales/zh-CN.ts
+++ b/ui/src/i18n/locales/zh-CN.ts
@@ -12,7 +12,9 @@ export const zh_CN: TranslationMap = {
disabled: "已禁用",
na: "不适用",
docs: "文档",
+ theme: "主题",
resources: "资源",
+ search: "搜索",
},
nav: {
chat: "聊天",
@@ -21,6 +23,7 @@ export const zh_CN: TranslationMap = {
settings: "设置",
expand: "展开侧边栏",
collapse: "折叠侧边栏",
+ resize: "调整侧边栏大小",
},
tabs: {
agents: "代理",
@@ -34,23 +37,33 @@ export const zh_CN: TranslationMap = {
nodes: "节点",
chat: "聊天",
config: "配置",
+ communications: "通信",
+ appearance: "外观与设置",
+ automation: "自动化",
+ infrastructure: "基础设施",
+ aiAgents: "AI 与代理",
debug: "调试",
logs: "日志",
},
subtitles: {
- agents: "管理代理工作区、工具和身份。",
- overview: "网关状态、入口点和快速健康读取。",
- channels: "管理频道和设置。",
- instances: "来自已连接客户端和节点的在线信号。",
- sessions: "检查活动会话并调整每个会话的默认设置。",
- usage: "监控 API 使用情况和成本。",
- cron: "安排唤醒和重复的代理运行。",
- skills: "管理技能可用性和 API 密钥注入。",
- nodes: "配对设备、功能和命令公开。",
- chat: "用于快速干预的直接网关聊天会话。",
- config: "安全地编辑 ~/.openclaw/openclaw.json。",
- debug: "网关快照、事件和手动 RPC 调用。",
- logs: "网关文件日志的实时追踪。",
+ agents: "工作区、工具、身份。",
+ overview: "状态、入口点、健康。",
+ channels: "频道和设置。",
+ instances: "已连接客户端和节点。",
+ sessions: "活动会话和默认设置。",
+ usage: "API 使用情况和成本。",
+ cron: "唤醒和重复运行。",
+ skills: "技能和 API 密钥。",
+ nodes: "配对设备和命令。",
+ chat: "网关聊天,快速干预。",
+ config: "编辑 openclaw.json。",
+ communications: "频道、消息和音频设置。",
+ appearance: "主题、界面和设置向导设置。",
+ automation: "命令、钩子、定时任务和插件设置。",
+ infrastructure: "网关、Web、浏览器和媒体设置。",
+ aiAgents: "代理、模型、技能、工具、记忆和会话设置。",
+ debug: "快照、事件、RPC。",
+ logs: "实时网关日志。",
},
overview: {
access: {
@@ -104,6 +117,47 @@ export const zh_CN: TranslationMap = {
hint: "此页面为 HTTP,因此浏览器阻止设备标识。请使用 HTTPS (Tailscale Serve) 或在网关主机上打开 {url}。",
stayHttp: "如果您必须保持 HTTP,请设置 {config} (仅限令牌)。",
},
+ connection: {
+ title: "如何连接",
+ step1: "在主机上启动网关:",
+ step2: "获取带令牌的仪表盘 URL:",
+ step3: "将 WebSocket URL 和令牌粘贴到上方,或直接打开带令牌的 URL。",
+ step4: "或生成可重复使用的令牌:",
+ docsHint: "如需远程访问,建议使用 Tailscale Serve。",
+ docsLink: "查看文档 →",
+ },
+ cards: {
+ cost: "费用",
+ skills: "技能",
+ recentSessions: "最近会话",
+ },
+ attention: {
+ title: "注意事项",
+ },
+ eventLog: {
+ title: "事件日志",
+ },
+ logTail: {
+ title: "网关日志",
+ },
+ quickActions: {
+ newSession: "新建会话",
+ automation: "自动化",
+ refreshAll: "全部刷新",
+ terminal: "终端",
+ },
+ streamMode: {
+ active: "流模式 — 数据已隐藏",
+ disable: "禁用",
+ },
+ palette: {
+ placeholder: "输入命令…",
+ noResults: "无结果",
+ },
+ },
+ login: {
+ subtitle: "网关仪表盘",
+ passwordPlaceholder: "可选",
},
chat: {
disconnected: "已断开与网关的连接。",
diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts
index 6fb48680e75..580f8a3de92 100644
--- a/ui/src/i18n/locales/zh-TW.ts
+++ b/ui/src/i18n/locales/zh-TW.ts
@@ -12,7 +12,9 @@ export const zh_TW: TranslationMap = {
disabled: "已禁用",
na: "不適用",
docs: "文檔",
+ theme: "主題",
resources: "資源",
+ search: "搜尋",
},
nav: {
chat: "聊天",
@@ -21,6 +23,7 @@ export const zh_TW: TranslationMap = {
settings: "設置",
expand: "展開側邊欄",
collapse: "折疊側邊欄",
+ resize: "調整側邊欄大小",
},
tabs: {
agents: "代理",
@@ -34,23 +37,33 @@ export const zh_TW: TranslationMap = {
nodes: "節點",
chat: "聊天",
config: "配置",
+ communications: "通訊",
+ appearance: "外觀與設置",
+ automation: "自動化",
+ infrastructure: "基礎設施",
+ aiAgents: "AI 與代理",
debug: "調試",
logs: "日誌",
},
subtitles: {
- agents: "管理代理工作區、工具和身份。",
- overview: "網關狀態、入口點和快速健康讀取。",
- channels: "管理頻道和設置。",
- instances: "來自已連接客戶端和節點的在線信號。",
- sessions: "檢查活動會話並調整每個會話的默認設置。",
- usage: "監控 API 使用情況和成本。",
- cron: "安排喚醒和重複的代理運行。",
- skills: "管理技能可用性和 API 密鑰注入。",
- nodes: "配對設備、功能和命令公開。",
- chat: "用於快速干預的直接網關聊天會話。",
- config: "安全地編輯 ~/.openclaw/openclaw.json。",
- debug: "網關快照、事件和手動 RPC 調用。",
- logs: "網關文件日志的實時追蹤。",
+ agents: "工作區、工具、身份。",
+ overview: "狀態、入口點、健康。",
+ channels: "頻道和設置。",
+ instances: "已連接客戶端和節點。",
+ sessions: "活動會話和默認設置。",
+ usage: "API 使用情況和成本。",
+ cron: "喚醒和重複運行。",
+ skills: "技能和 API 密鑰。",
+ nodes: "配對設備和命令。",
+ chat: "網關聊天,快速干預。",
+ config: "編輯 openclaw.json。",
+ communications: "頻道、消息和音頻設置。",
+ appearance: "主題、界面和設置向導設置。",
+ automation: "命令、鉤子、定時任務和插件設置。",
+ infrastructure: "網關、Web、瀏覽器和媒體設置。",
+ aiAgents: "代理、模型、技能、工具、記憶和會話設置。",
+ debug: "快照、事件、RPC。",
+ logs: "實時網關日誌。",
},
overview: {
access: {
@@ -104,6 +117,47 @@ export const zh_TW: TranslationMap = {
hint: "此頁面為 HTTP,因此瀏覽器阻止設備標識。請使用 HTTPS (Tailscale Serve) 或在網關主機上打開 {url}。",
stayHttp: "如果您必須保持 HTTP,請設置 {config} (僅限令牌)。",
},
+ connection: {
+ title: "如何連接",
+ step1: "在主機上啟動閘道:",
+ step2: "取得帶令牌的儀表板 URL:",
+ step3: "將 WebSocket URL 和令牌貼到上方,或直接開啟帶令牌的 URL。",
+ step4: "或產生可重複使用的令牌:",
+ docsHint: "如需遠端存取,建議使用 Tailscale Serve。",
+ docsLink: "查看文件 →",
+ },
+ cards: {
+ cost: "費用",
+ skills: "技能",
+ recentSessions: "最近會話",
+ },
+ attention: {
+ title: "注意事項",
+ },
+ eventLog: {
+ title: "事件日誌",
+ },
+ logTail: {
+ title: "閘道日誌",
+ },
+ quickActions: {
+ newSession: "新建會話",
+ automation: "自動化",
+ refreshAll: "全部刷新",
+ terminal: "終端",
+ },
+ streamMode: {
+ active: "串流模式 — 數據已隱藏",
+ disable: "禁用",
+ },
+ palette: {
+ placeholder: "輸入指令…",
+ noResults: "無結果",
+ },
+ },
+ login: {
+ subtitle: "閘道儀表板",
+ passwordPlaceholder: "可選",
},
chat: {
disconnected: "已斷開與網關的連接。",
diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts
index 178fd12b1e3..d373d3a47c9 100644
--- a/ui/src/i18n/test/translate.test.ts
+++ b/ui/src/i18n/test/translate.test.ts
@@ -1,56 +1,100 @@
-import { describe, it, expect, beforeEach, vi } from "vitest";
-import { i18n, t } from "../lib/translate.ts";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { pt_BR } from "../locales/pt-BR.ts";
+import { zh_CN } from "../locales/zh-CN.ts";
+import { zh_TW } from "../locales/zh-TW.ts";
+
+type TranslateModule = typeof import("../lib/translate.ts");
+
+function createStorageMock(): Storage {
+ const store = new Map();
+ return {
+ get length() {
+ return store.size;
+ },
+ clear() {
+ store.clear();
+ },
+ getItem(key: string) {
+ return store.get(key) ?? null;
+ },
+ key(index: number) {
+ return Array.from(store.keys())[index] ?? null;
+ },
+ removeItem(key: string) {
+ store.delete(key);
+ },
+ setItem(key: string, value: string) {
+ store.set(key, String(value));
+ },
+ };
+}
describe("i18n", () => {
+ let translate: TranslateModule;
+
beforeEach(async () => {
+ vi.resetModules();
+ vi.stubGlobal("localStorage", createStorageMock());
+ vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
+ translate = await import("../lib/translate.ts");
localStorage.clear();
// Reset to English
- await i18n.setLocale("en");
+ await translate.i18n.setLocale("en");
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
});
it("should return the key if translation is missing", () => {
- expect(t("non.existent.key")).toBe("non.existent.key");
+ expect(translate.t("non.existent.key")).toBe("non.existent.key");
});
it("should return the correct English translation", () => {
- expect(t("common.health")).toBe("Health");
+ expect(translate.t("common.health")).toBe("Health");
});
it("should replace parameters correctly", () => {
- expect(t("overview.stats.cronNext", { time: "10:00" })).toBe("Next wake 10:00");
+ expect(translate.t("overview.stats.cronNext", { time: "10:00" })).toBe("Next wake 10:00");
});
it("should fallback to English if key is missing in another locale", async () => {
// We haven't registered other locales in the test environment yet,
// but the logic should fallback to 'en' map which is always there.
- await i18n.setLocale("zh-CN");
+ await translate.i18n.setLocale("zh-CN");
// Since we don't mock the import, it might fail to load zh-CN,
// but let's assume it falls back to English for now.
- expect(t("common.health")).toBeDefined();
+ expect(translate.t("common.health")).toBeDefined();
});
it("loads translations even when setting the same locale again", async () => {
- const internal = i18n as unknown as {
+ const internal = translate.i18n as unknown as {
locale: string;
translations: Record;
};
internal.locale = "zh-CN";
delete internal.translations["zh-CN"];
- await i18n.setLocale("zh-CN");
- expect(t("common.health")).toBe("健康状况");
+ await translate.i18n.setLocale("zh-CN");
+ expect(translate.t("common.health")).toBe("健康状况");
});
it("loads saved non-English locale on startup", async () => {
- localStorage.setItem("openclaw.i18n.locale", "zh-CN");
vi.resetModules();
+ vi.stubGlobal("localStorage", createStorageMock());
+ vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
+ localStorage.setItem("openclaw.i18n.locale", "zh-CN");
const fresh = await import("../lib/translate.ts");
-
- for (let index = 0; index < 5 && fresh.i18n.getLocale() !== "zh-CN"; index += 1) {
- await Promise.resolve();
- }
-
+ await vi.waitFor(() => {
+ expect(fresh.i18n.getLocale()).toBe("zh-CN");
+ });
expect(fresh.i18n.getLocale()).toBe("zh-CN");
expect(fresh.t("common.health")).toBe("健康状况");
});
+
+ it("keeps the version label available in shipped locales", () => {
+ expect((pt_BR.common as { version?: string }).version).toBeTruthy();
+ expect((zh_CN.common as { version?: string }).version).toBeTruthy();
+ expect((zh_TW.common as { version?: string }).version).toBeTruthy();
+ });
});
diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts
index 68dfbe5e76d..0678706cd04 100644
--- a/ui/src/ui/app-render.helpers.ts
+++ b/ui/src/ui/app-render.helpers.ts
@@ -490,7 +490,7 @@ function countHiddenCronSessions(sessionKey: string, sessions: SessionsListResul
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
export function renderThemeToggle(state: AppViewState) {
- const index = Math.max(0, THEME_ORDER.indexOf(state.theme));
+ const index = Math.max(0, THEME_ORDER.indexOf(state.themeMode));
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
const element = event.currentTarget as HTMLElement;
const context: ThemeTransitionContext = { element };
@@ -498,7 +498,7 @@ export function renderThemeToggle(state: AppViewState) {
context.pointerClientX = event.clientX;
context.pointerClientY = event.clientY;
}
- state.setTheme(next, context);
+ state.setThemeMode(next, context);
};
return html`
@@ -506,27 +506,27 @@ export function renderThemeToggle(state: AppViewState) {