From ccc7003360cf0bf9e529ce41b7b23cfcce9021a4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 10 Mar 2026 08:50:30 -0400 Subject: [PATCH 01/48] Changelog: add unreleased March 9 entries --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f705ed77a3..adc8b8d6e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - 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. +- 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. ### Breaking @@ -40,6 +41,8 @@ Docs: https://docs.openclaw.ai - Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf. - Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk. - Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu. +- Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc. +- Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc. ## 2026.3.8 From 7a8316706c8c7d5720b4a964ebb4aae5ff78fbec Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 10 Mar 2026 10:49:39 -0400 Subject: [PATCH 02/48] Tests: cover grapheme terminal width --- src/terminal/ansi.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/terminal/ansi.test.ts b/src/terminal/ansi.test.ts index 30ae4c82eb3..3970868d3f8 100644 --- a/src/terminal/ansi.test.ts +++ b/src/terminal/ansi.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { sanitizeForLog, stripAnsi } from "./ansi.js"; +import { sanitizeForLog, splitGraphemes, stripAnsi, visibleWidth } from "./ansi.js"; describe("terminal ansi helpers", () => { it("strips ANSI and OSC8 sequences", () => { @@ -11,4 +11,16 @@ describe("terminal ansi helpers", () => { const input = "\u001B[31mwarn\u001B[0m\r\nnext\u0000line\u007f"; expect(sanitizeForLog(input)).toBe("warnnextline"); }); + + it("measures wide graphemes by terminal cell width", () => { + expect(visibleWidth("abc")).toBe(3); + expect(visibleWidth("๐Ÿ“ธ skill")).toBe(8); + expect(visibleWidth("่กจ")).toBe(2); + expect(visibleWidth("\u001B[31m๐Ÿ“ธ\u001B[0m")).toBe(2); + }); + + it("keeps emoji zwj sequences as single graphemes", () => { + expect(splitGraphemes("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ")).toEqual(["๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"]); + expect(visibleWidth("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ")).toBe(2); + }); }); From 4efe7a4dcd0b6b428f2bd0d00816ba83cfd4f955 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 10 Mar 2026 10:50:01 -0400 Subject: [PATCH 03/48] Terminal: measure grapheme display width --- src/terminal/ansi.ts | 91 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/src/terminal/ansi.ts b/src/terminal/ansi.ts index d9adaa38633..471611fcc2e 100644 --- a/src/terminal/ansi.ts +++ b/src/terminal/ansi.ts @@ -4,11 +4,29 @@ const OSC8_PATTERN = "\\x1b\\]8;;.*?\\x1b\\\\|\\x1b\\]8;;\\x1b\\\\"; const ANSI_REGEX = new RegExp(ANSI_SGR_PATTERN, "g"); const OSC8_REGEX = new RegExp(OSC8_PATTERN, "g"); +const graphemeSegmenter = + typeof Intl !== "undefined" && "Segmenter" in Intl + ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) + : null; export function stripAnsi(input: string): string { return input.replace(OSC8_REGEX, "").replace(ANSI_REGEX, ""); } +export function splitGraphemes(input: string): string[] { + if (!input) { + return []; + } + if (!graphemeSegmenter) { + return Array.from(input); + } + try { + return Array.from(graphemeSegmenter.segment(input), (segment) => segment.segment); + } catch { + return Array.from(input); + } +} + /** * Sanitize a value for safe interpolation into log messages. * Strips ANSI escape sequences, C0 control characters (U+0000โ€“U+001F), @@ -22,6 +40,75 @@ export function sanitizeForLog(v: string): string { return out.replaceAll(String.fromCharCode(0x7f), ""); } -export function visibleWidth(input: string): number { - return Array.from(stripAnsi(input)).length; +function isZeroWidthCodePoint(codePoint: number): boolean { + return ( + (codePoint >= 0x0300 && codePoint <= 0x036f) || + (codePoint >= 0x1ab0 && codePoint <= 0x1aff) || + (codePoint >= 0x1dc0 && codePoint <= 0x1dff) || + (codePoint >= 0x20d0 && codePoint <= 0x20ff) || + (codePoint >= 0xfe20 && codePoint <= 0xfe2f) || + (codePoint >= 0xfe00 && codePoint <= 0xfe0f) || + codePoint === 0x200d + ); +} + +function isFullWidthCodePoint(codePoint: number): boolean { + if (codePoint < 0x1100) { + return false; + } + return ( + codePoint <= 0x115f || + codePoint === 0x2329 || + codePoint === 0x232a || + (codePoint >= 0x2e80 && codePoint <= 0x3247 && codePoint !== 0x303f) || + (codePoint >= 0x3250 && codePoint <= 0x4dbf) || + (codePoint >= 0x4e00 && codePoint <= 0xa4c6) || + (codePoint >= 0xa960 && codePoint <= 0xa97c) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6b) || + (codePoint >= 0xff01 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x1aff0 && codePoint <= 0x1aff3) || + (codePoint >= 0x1aff5 && codePoint <= 0x1affb) || + (codePoint >= 0x1affd && codePoint <= 0x1affe) || + (codePoint >= 0x1b000 && codePoint <= 0x1b2ff) || + (codePoint >= 0x1f200 && codePoint <= 0x1f251) || + (codePoint >= 0x20000 && codePoint <= 0x3fffd) + ); +} + +const emojiLikePattern = /[\p{Extended_Pictographic}\p{Regional_Indicator}\u20e3]/u; + +function graphemeWidth(grapheme: string): number { + if (!grapheme) { + return 0; + } + if (emojiLikePattern.test(grapheme)) { + return 2; + } + + let sawPrintable = false; + for (const char of grapheme) { + const codePoint = char.codePointAt(0); + if (codePoint == null) { + continue; + } + if (isZeroWidthCodePoint(codePoint)) { + continue; + } + if (isFullWidthCodePoint(codePoint)) { + return 2; + } + sawPrintable = true; + } + return sawPrintable ? 1 : 0; +} + +export function visibleWidth(input: string): number { + return splitGraphemes(stripAnsi(input)).reduce( + (sum, grapheme) => sum + graphemeWidth(grapheme), + 0, + ); } From 1ec49e33f3e7086041cd12a759043ae342381513 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 10 Mar 2026 10:50:11 -0400 Subject: [PATCH 04/48] Terminal: wrap table cells by grapheme width --- src/terminal/table.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/terminal/table.ts b/src/terminal/table.ts index 34d7b15dd05..6f00b1b2064 100644 --- a/src/terminal/table.ts +++ b/src/terminal/table.ts @@ -1,5 +1,5 @@ import { displayString } from "../utils.js"; -import { visibleWidth } from "./ansi.js"; +import { splitGraphemes, visibleWidth } from "./ansi.js"; type Align = "left" | "right" | "center"; @@ -94,13 +94,15 @@ function wrapLine(text: string, width: number): string[] { } } - const cp = text.codePointAt(i); - if (!cp) { - break; + let nextEsc = text.indexOf(ESC, i); + if (nextEsc < 0) { + nextEsc = text.length; } - const ch = String.fromCodePoint(cp); - tokens.push({ kind: "char", value: ch }); - i += ch.length; + const plainChunk = text.slice(i, nextEsc); + for (const grapheme of splitGraphemes(plainChunk)) { + tokens.push({ kind: "char", value: grapheme }); + } + i = nextEsc; } const firstCharIndex = tokens.findIndex((t) => t.kind === "char"); @@ -139,7 +141,7 @@ function wrapLine(text: string, width: number): string[] { const bufToString = (slice?: Token[]) => (slice ?? buf).map((t) => t.value).join(""); const bufVisibleWidth = (slice: Token[]) => - slice.reduce((acc, t) => acc + (t.kind === "char" ? 1 : 0), 0); + slice.reduce((acc, t) => acc + (t.kind === "char" ? visibleWidth(t.value) : 0), 0); const pushLine = (value: string) => { const cleaned = value.replace(/\s+$/, ""); @@ -195,12 +197,13 @@ function wrapLine(text: string, width: number): string[] { } continue; } - if (bufVisible + 1 > width && bufVisible > 0) { + const charWidth = visibleWidth(ch); + if (bufVisible + charWidth > width && bufVisible > 0) { flushAt(lastBreakIndex); } buf.push(token); - bufVisible += 1; + bufVisible += charWidth; if (isBreakChar(ch)) { lastBreakIndex = buf.length; } From a7a5e01c4c20bfebfabd4cbaa27a96b09cb5e8cc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 10 Mar 2026 10:50:21 -0400 Subject: [PATCH 05/48] Tests: cover emoji table alignment --- src/terminal/table.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/terminal/table.test.ts b/src/terminal/table.test.ts index bb6f2082fe3..cc7e110ea40 100644 --- a/src/terminal/table.test.ts +++ b/src/terminal/table.test.ts @@ -99,6 +99,31 @@ describe("renderTable", () => { expect(line1Index).toBeGreaterThan(-1); expect(line2Index).toBe(line1Index + 1); }); + + it("keeps table borders aligned when cells contain wide emoji graphemes", () => { + const width = 72; + const out = renderTable({ + width, + columns: [ + { key: "Status", header: "Status", minWidth: 10 }, + { key: "Skill", header: "Skill", minWidth: 18 }, + { key: "Description", header: "Description", minWidth: 18, flex: true }, + { key: "Source", header: "Source", minWidth: 10 }, + ], + rows: [ + { + Status: "โœ— missing", + Skill: "๐Ÿ“ธ peekaboo", + Description: "Capture screenshots from macOS windows and keep table wrapping stable.", + Source: "openclaw-bundled", + }, + ], + }); + + for (const line of out.trimEnd().split("\n")) { + expect(visibleWidth(line)).toBe(width); + } + }); }); describe("wrapNoteMessage", () => { From f7f75519add7f4ebe4a3a16ca322bf8765b70b27 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 10 Mar 2026 22:30:44 -0400 Subject: [PATCH 06/48] Deps: patch file-type and hono --- package.json | 6 ++++-- pnpm-lock.yaml | 51 ++++++++++++++++++++++++++------------------------ 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index bc625b74e71..18d17b95859 100644 --- a/package.json +++ b/package.json @@ -362,8 +362,9 @@ "discord-api-types": "^0.38.41", "dotenv": "^17.3.1", "express": "^5.2.1", - "file-type": "^21.3.0", + "file-type": "^21.3.1", "grammy": "^1.41.1", + "hono": "4.12.7", "https-proxy-agent": "^7.0.6", "ipaddr.js": "^2.3.0", "jiti": "^2.6.1", @@ -420,7 +421,8 @@ "pnpm": { "minimumReleaseAge": 2880, "overrides": { - "hono": "4.12.5", + "file-type": "21.3.1", + "hono": "4.12.7", "@hono/node-server": "1.19.10", "fast-xml-parser": "5.3.8", "request": "npm:@cypress/request@3.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ae9ea71e0c..0e68b4b0bfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: - hono: 4.12.5 + file-type: 21.3.1 + hono: 4.12.7 '@hono/node-server': 1.19.10 fast-xml-parser: 5.3.8 request: npm:@cypress/request@3.0.10 @@ -32,7 +33,7 @@ importers: version: 3.1004.0 '@buape/carbon': specifier: 0.0.0-beta-20260216184201 - version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1) + version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1) '@clack/prompts': specifier: ^1.1.0 version: 1.1.0 @@ -115,11 +116,14 @@ importers: specifier: ^5.2.1 version: 5.2.1 file-type: - specifier: ^21.3.0 - version: 21.3.0 + specifier: 21.3.1 + version: 21.3.1 grammy: specifier: ^1.41.1 version: 1.41.1 + hono: + specifier: 4.12.7 + version: 4.12.7 https-proxy-agent: specifier: ^7.0.6 version: 7.0.6 @@ -339,7 +343,7 @@ importers: version: 10.6.1 openclaw: specifier: '>=2026.3.2' - version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)) + version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.7)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/imessage: {} @@ -400,7 +404,7 @@ importers: dependencies: openclaw: specifier: '>=2026.3.2' - version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)) + version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.7)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/memory-lancedb: dependencies: @@ -1278,7 +1282,7 @@ packages: resolution: {integrity: sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: 4.12.5 + hono: 4.12.7 '@huggingface/jinja@0.5.5': resolution: {integrity: sha512-xRlzazC+QZwr6z4ixEqYHo9fgwhTZ3xNSdljlKfUFGZSdlvt166DljRELFUfFytlYOYvo3vTisA/AFOuOAzFQQ==} @@ -4440,8 +4444,8 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} - file-type@21.3.0: - resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} + file-type@21.3.1: + resolution: {integrity: sha512-SrzXX46I/zsRDjTb82eucsGg0ODq2NpGDp4HcsFKApPy8P8vACjpJRDoGGMfEzhFC0ry61ajd7f72J3603anBA==} engines: {node: '>=20'} filename-reserved-regex@3.0.0: @@ -4661,8 +4665,8 @@ packages: highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} - hono@4.12.5: - resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==} + hono@4.12.7: + resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} engines: {node: '>=16.9.0'} hookable@6.0.1: @@ -7820,14 +7824,14 @@ snapshots: '@borewit/text-codec@0.2.1': {} - '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1)': + '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1)': dependencies: '@types/node': 25.3.5 discord-api-types: 0.38.37 optionalDependencies: '@cloudflare/workers-types': 4.20260120.0 '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) - '@hono/node-server': 1.19.10(hono@4.12.5) + '@hono/node-server': 1.19.10(hono@4.12.7) '@types/bun': 1.3.9 '@types/ws': 8.18.1 ws: 8.19.0 @@ -8171,9 +8175,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@hono/node-server@1.19.10(hono@4.12.5)': + '@hono/node-server@1.19.10(hono@4.12.7)': dependencies: - hono: 4.12.5 + hono: 4.12.7 optional: true '@huggingface/jinja@0.5.5': {} @@ -8584,7 +8588,7 @@ snapshots: cli-highlight: 2.1.11 diff: 8.0.3 extract-zip: 2.0.1 - file-type: 21.3.0 + file-type: 21.3.1 glob: 13.0.6 hosted-git-info: 9.0.2 ignore: 7.0.5 @@ -8615,7 +8619,7 @@ snapshots: cli-highlight: 2.1.11 diff: 8.0.3 extract-zip: 2.0.1 - file-type: 21.3.0 + file-type: 21.3.1 glob: 13.0.6 hosted-git-info: 9.0.2 ignore: 7.0.5 @@ -11655,7 +11659,7 @@ snapshots: node-domexception: '@nolyfill/domexception@1.0.28' web-streams-polyfill: 3.3.3 - file-type@21.3.0: + file-type@21.3.1: dependencies: '@tokenizer/inflate': 0.4.1 strtok3: 10.3.4 @@ -11942,8 +11946,7 @@ snapshots: highlight.js@10.7.3: {} - hono@4.12.5: - optional: true + hono@4.12.7: {} hookable@6.0.1: {} @@ -12598,7 +12601,7 @@ snapshots: '@tokenizer/token': 0.3.0 content-type: 1.0.5 debug: 4.4.3 - file-type: 21.3.0 + file-type: 21.3.1 media-typer: 1.1.0 strtok3: 10.3.4 token-types: 6.1.2 @@ -12821,11 +12824,11 @@ snapshots: ws: 8.19.0 zod: 4.3.6 - openclaw@2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)): + openclaw@2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.7)(node-llama-cpp@3.16.2(typescript@5.9.3)): dependencies: '@agentclientprotocol/sdk': 0.14.1(zod@4.3.6) '@aws-sdk/client-bedrock': 3.1000.0 - '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1) + '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1) '@clack/prompts': 1.0.1 '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) '@grammyjs/runner': 2.0.3(grammy@1.41.0) @@ -12854,7 +12857,7 @@ snapshots: discord-api-types: 0.38.40 dotenv: 17.3.1 express: 5.2.1 - file-type: 21.3.0 + file-type: 21.3.1 gaxios: 7.1.3 google-auth-library: 10.6.1 grammy: 1.41.0 From 0d7db6c6529622bb2efa402e3095b8e63267dfe8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 11 Mar 2026 01:04:18 -0400 Subject: [PATCH 07/48] Update CHANGELOG.md --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adc8b8d6e16..0ceb494bc4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,8 +41,6 @@ Docs: https://docs.openclaw.ai - Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf. - Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk. - Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu. -- Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc. -- Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc. ## 2026.3.8 From c58fffdab6541cd332a054d4ee5f0728075dc8aa Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 11 Mar 2026 01:39:43 -0400 Subject: [PATCH 08/48] Terminal: refine table wrapping and width handling --- src/terminal/table.test.ts | 32 ++++++++++++++++++++++++++++++++ src/terminal/table.ts | 26 ++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/terminal/table.test.ts b/src/terminal/table.test.ts index cc7e110ea40..f6efea97609 100644 --- a/src/terminal/table.test.ts +++ b/src/terminal/table.test.ts @@ -83,6 +83,38 @@ describe("renderTable", () => { } }); + it("trims leading spaces on wrapped ANSI-colored continuation lines", () => { + const out = renderTable({ + width: 113, + columns: [ + { key: "Status", header: "Status", minWidth: 10 }, + { key: "Skill", header: "Skill", minWidth: 18, flex: true }, + { key: "Description", header: "Description", minWidth: 24, flex: true }, + { key: "Source", header: "Source", minWidth: 10 }, + ], + rows: [ + { + Status: "โœ“ ready", + Skill: "๐ŸŒค๏ธ weather", + Description: + `\x1b[2mGet current weather and forecasts via wttr.in or Open-Meteo. ` + + `Use when: user asks about weather, temperature, or forecasts for any location.` + + `\x1b[0m`, + Source: "openclaw-bundled", + }, + ], + }); + + const lines = out + .trimEnd() + .split("\n") + .filter((line) => line.includes("Use when")); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("\u001b[2mUse when"); + expect(lines[0]).not.toContain("โ”‚ Use when"); + expect(lines[0]).not.toContain("โ”‚ \x1b[2m Use when"); + }); + it("respects explicit newlines in cell values", () => { const out = renderTable({ width: 48, diff --git a/src/terminal/table.ts b/src/terminal/table.ts index 6f00b1b2064..2945e47019c 100644 --- a/src/terminal/table.ts +++ b/src/terminal/table.ts @@ -151,6 +151,20 @@ function wrapLine(text: string, width: number): string[] { lines.push(cleaned); }; + const trimLeadingSpaces = (tokens: Token[]) => { + while (true) { + const firstCharIndex = tokens.findIndex((token) => token.kind === "char"); + if (firstCharIndex < 0) { + return; + } + const firstChar = tokens[firstCharIndex]; + if (!firstChar || !isSpaceChar(firstChar.value)) { + return; + } + tokens.splice(firstCharIndex, 1); + } + }; + const flushAt = (breakAt: number | null) => { if (buf.length === 0) { return; @@ -166,10 +180,7 @@ function wrapLine(text: string, width: number): string[] { const left = buf.slice(0, breakAt); const rest = buf.slice(breakAt); pushLine(bufToString(left)); - - while (rest.length > 0 && rest[0]?.kind === "char" && isSpaceChar(rest[0].value)) { - rest.shift(); - } + trimLeadingSpaces(rest); buf.length = 0; buf.push(...rest); @@ -201,6 +212,9 @@ function wrapLine(text: string, width: number): string[] { if (bufVisible + charWidth > width && bufVisible > 0) { flushAt(lastBreakIndex); } + if (bufVisible === 0 && isSpaceChar(ch)) { + continue; + } buf.push(token); bufVisible += charWidth; @@ -234,6 +248,10 @@ function normalizeWidth(n: number | undefined): number | undefined { return Math.floor(n); } +export function getTerminalTableWidth(minWidth = 60, fallbackWidth = 120): number { + return Math.max(minWidth, process.stdout.columns ?? fallbackWidth); +} + export function renderTable(opts: RenderTableOptions): string { const rows = opts.rows.map((row) => { const next: Record = {}; From 209decf25cd7db618248bccb727cf76138db823f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 11 Mar 2026 01:40:01 -0400 Subject: [PATCH 09/48] Terminal: stop shrinking CLI tables by one column --- src/cli/devices-cli.ts | 6 +++--- src/cli/directory-cli.ts | 6 +++--- src/cli/dns-cli.ts | 4 ++-- src/cli/exec-approvals-cli.ts | 4 ++-- src/cli/hooks-cli.ts | 4 ++-- src/cli/nodes-cli/register.camera.ts | 4 ++-- src/cli/nodes-cli/register.pairing.ts | 3 ++- src/cli/nodes-cli/register.status.ts | 8 ++++---- src/cli/pairing-cli.ts | 4 ++-- src/cli/plugins-cli.ts | 4 ++-- src/cli/skills-cli.format.ts | 8 ++++---- src/cli/update-cli/status.ts | 4 ++-- src/commands/message-format.ts | 4 ++-- src/commands/models/list.status-command.ts | 4 ++-- src/commands/status-all/report-lines.ts | 4 ++-- src/commands/status.command.ts | 4 ++-- 16 files changed, 38 insertions(+), 37 deletions(-) diff --git a/src/cli/devices-cli.ts b/src/cli/devices-cli.ts index 0344bf7967a..143d27b20ff 100644 --- a/src/cli/devices-cli.ts +++ b/src/cli/devices-cli.ts @@ -9,7 +9,7 @@ import { } from "../infra/device-pairing.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { defaultRuntime } from "../runtime.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { withProgress } from "./progress.js"; @@ -224,7 +224,7 @@ export function registerDevicesCli(program: Command) { return; } if (list.pending?.length) { - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); defaultRuntime.log( `${theme.heading("Pending")} ${theme.muted(`(${list.pending.length})`)}`, ); @@ -251,7 +251,7 @@ export function registerDevicesCli(program: Command) { ); } if (list.paired?.length) { - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); defaultRuntime.log( `${theme.heading("Paired")} ${theme.muted(`(${list.paired.length})`)}`, ); diff --git a/src/cli/directory-cli.ts b/src/cli/directory-cli.ts index d11867fbb40..1a9949f224a 100644 --- a/src/cli/directory-cli.ts +++ b/src/cli/directory-cli.ts @@ -6,7 +6,7 @@ import { danger } from "../globals.js"; import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { formatHelpExamples } from "./help-format.js"; @@ -48,7 +48,7 @@ function printDirectoryList(params: { return; } - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); defaultRuntime.log(`${theme.heading(params.title)} ${theme.muted(`(${params.entries.length})`)}`); defaultRuntime.log( renderTable({ @@ -166,7 +166,7 @@ export function registerDirectoryCli(program: Command) { defaultRuntime.log(theme.muted("Not available.")); return; } - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); defaultRuntime.log(theme.heading("Self")); defaultRuntime.log( renderTable({ diff --git a/src/cli/dns-cli.ts b/src/cli/dns-cli.ts index de6e6c0dec0..f9781d2f38e 100644 --- a/src/cli/dns-cli.ts +++ b/src/cli/dns-cli.ts @@ -7,7 +7,7 @@ import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet import { getWideAreaZonePath, resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; type RunOpts = { allowFailure?: boolean; inherit?: boolean }; @@ -133,7 +133,7 @@ export function registerDnsCli(program: Command) { } const zonePath = getWideAreaZonePath(wideAreaDomain); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); defaultRuntime.log(theme.heading("DNS setup")); defaultRuntime.log( renderTable({ diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index 07fe5a462a6..c243fb7a0aa 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -10,7 +10,7 @@ import { import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { isRich, theme } from "../terminal/theme.js"; import { describeUnknownError } from "./gateway-cli/shared.js"; import { callGatewayFromCli } from "./gateway-rpc.js"; @@ -151,7 +151,7 @@ function renderApprovalsSnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: s const rich = isRich(); const heading = (text: string) => (rich ? theme.heading(text) : text); const muted = (text: string) => (rich ? theme.muted(text) : text); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const file = snapshot.file ?? { version: 1 }; const defaults = file.defaults ?? {}; diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index 7ea0de030da..85aa0d0e4b9 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -22,7 +22,7 @@ import { resolveArchiveKind } from "../infra/archive.js"; import { buildPluginStatusReport } from "../plugins/status.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { resolveUserPath, shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; @@ -273,7 +273,7 @@ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions } const eligible = hooks.filter((h) => h.eligible); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const rows = hooks.map((hook) => { const missing = formatHookMissingSummary(hook); return { diff --git a/src/cli/nodes-cli/register.camera.ts b/src/cli/nodes-cli/register.camera.ts index 3bd7d1203dc..82cde2a35f3 100644 --- a/src/cli/nodes-cli/register.camera.ts +++ b/src/cli/nodes-cli/register.camera.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import { defaultRuntime } from "../../runtime.js"; -import { renderTable } from "../../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { shortenHomePath } from "../../utils.js"; import { type CameraFacing, @@ -71,7 +71,7 @@ export function registerNodesCameraCommands(nodes: Command) { } const { heading, muted } = getNodesTheme(); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const rows = devices.map((device) => ({ Name: typeof device.name === "string" ? device.name : "Unknown Camera", Position: typeof device.position === "string" ? device.position : muted("unspecified"), diff --git a/src/cli/nodes-cli/register.pairing.ts b/src/cli/nodes-cli/register.pairing.ts index b20c989c1c7..fd649fae754 100644 --- a/src/cli/nodes-cli/register.pairing.ts +++ b/src/cli/nodes-cli/register.pairing.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { defaultRuntime } from "../../runtime.js"; +import { getTerminalTableWidth } from "../../terminal/table.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { parsePairingList } from "./format.js"; import { renderPendingPairingRequestsTable } from "./pairing-render.js"; @@ -25,7 +26,7 @@ export function registerNodesPairingCommands(nodes: Command) { return; } const { heading, warn, muted } = getNodesTheme(); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const now = Date.now(); const rendered = renderPendingPairingRequestsTable({ pending, diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index 4dcb3be8e38..03e00cbbec4 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; import { defaultRuntime } from "../../runtime.js"; -import { renderTable } from "../../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { shortenHomeInString } from "../../utils.js"; import { parseDurationMs } from "../parse-duration.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; @@ -112,7 +112,7 @@ export function registerNodesStatusCommands(nodes: Command) { const obj: Record = typeof result === "object" && result !== null ? result : {}; const { ok, warn, muted } = getNodesTheme(); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const now = Date.now(); const nodes = parseNodeList(result); const lastConnectedById = @@ -256,7 +256,7 @@ export function registerNodesStatusCommands(nodes: Command) { const status = `${paired ? ok("paired") : warn("unpaired")} ยท ${ connected ? ok("connected") : muted("disconnected") }`; - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const rows = [ { Field: "ID", Value: nodeId }, displayName ? { Field: "Name", Value: displayName } : null, @@ -307,7 +307,7 @@ export function registerNodesStatusCommands(nodes: Command) { const result = await callGatewayCli("node.pair.list", opts, {}); const { pending, paired } = parsePairingList(result); const { heading, muted, warn } = getNodesTheme(); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const now = Date.now(); const hasFilters = connectedOnly || sinceMs !== undefined; const pendingRows = hasFilters ? [] : pending; diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index 6974663bd49..7c8cbc750ea 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -10,7 +10,7 @@ import { } from "../pairing/pairing-store.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { formatCliCommand } from "./command-format.js"; @@ -88,7 +88,7 @@ export function registerPairingCli(program: Command) { return; } const idLabel = resolvePairingIdLabel(channel); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); defaultRuntime.log( `${theme.heading("Pairing requests")} ${theme.muted(`(${requests.length})`)}`, ); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 36e198c71a2..e77d7026875 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -19,7 +19,7 @@ import { resolveUninstallDirectoryTarget, uninstallPlugin } from "../plugins/uni import { updateNpmInstalledPlugins } from "../plugins/update.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { resolveUserPath, shortenHomeInString, shortenHomePath } from "../utils.js"; import { looksLikeLocalInstallSpec } from "./install-spec.js"; @@ -404,7 +404,7 @@ export function registerPluginsCli(program: Command) { ); if (!opts.verbose) { - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const sourceRoots = resolvePluginSourceRoots({ workspaceDir: report.workspaceDir, }); diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts index 5f6dcfdcd2a..dc335fb6c21 100644 --- a/src/cli/skills-cli.format.ts +++ b/src/cli/skills-cli.format.ts @@ -1,5 +1,5 @@ import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; @@ -39,7 +39,7 @@ function formatSkillStatus(skill: SkillStatusEntry): string { } function formatSkillName(skill: SkillStatusEntry): string { - const emoji = skill.emoji ?? "๐Ÿ“ฆ"; + const emoji = (skill.emoji ?? "๐Ÿ“ฆ").replaceAll("\uFE0E", "\uFE0F"); return `${emoji} ${theme.command(skill.name)}`; } @@ -95,7 +95,7 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti } const eligible = skills.filter((s) => s.eligible); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const rows = skills.map((skill) => { const missing = formatSkillMissingSummary(skill); return { @@ -109,7 +109,7 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti const columns = [ { key: "Status", header: "Status", minWidth: 10 }, - { key: "Skill", header: "Skill", minWidth: 18, flex: true }, + { key: "Skill", header: "Skill", minWidth: 22 }, { key: "Description", header: "Description", minWidth: 24, flex: true }, { key: "Source", header: "Source", minWidth: 10 }, ]; diff --git a/src/cli/update-cli/status.ts b/src/cli/update-cli/status.ts index 5cf2bf8af49..8266a1e5f21 100644 --- a/src/cli/update-cli/status.ts +++ b/src/cli/update-cli/status.ts @@ -10,7 +10,7 @@ import { } from "../../infra/update-channels.js"; import { checkUpdateStatus } from "../../infra/update-check.js"; import { defaultRuntime } from "../../runtime.js"; -import { renderTable } from "../../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { theme } from "../../terminal/theme.js"; import { parseTimeoutMsOrExit, resolveUpdateRoot, type UpdateStatusOptions } from "./shared.js"; @@ -89,7 +89,7 @@ export async function updateStatusCommand(opts: UpdateStatusOptions): Promise (rich ? theme.muted(text) : text); const heading = (text: string) => (rich ? theme.heading(text) : text); - const width = Math.max(60, (process.stdout.columns ?? 120) - 1); + const width = getTerminalTableWidth(); const opts: FormatOpts = { width }; if (result.handledBy === "dry-run") { diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index 59614e3f866..156860bb960 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -38,7 +38,7 @@ import { } from "../../infra/provider-usage.js"; import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js"; import type { RuntimeEnv } from "../../runtime.js"; -import { renderTable } from "../../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { colorize, theme } from "../../terminal/theme.js"; import { shortenHomePath } from "../../utils.js"; import { resolveProviderAuthOverview } from "./list.auth-overview.js"; @@ -631,7 +631,7 @@ export async function modelsStatusCommand( if (probeSummary.results.length === 0) { runtime.log(colorize(rich, theme.muted, "- none")); } else { - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const sorted = sortProbeResults(probeSummary.results); const statusColor = (status: string) => { if (status === "ok") { diff --git a/src/commands/status-all/report-lines.ts b/src/commands/status-all/report-lines.ts index 152918029b5..751237360b4 100644 --- a/src/commands/status-all/report-lines.ts +++ b/src/commands/status-all/report-lines.ts @@ -1,5 +1,5 @@ import type { ProgressReporter } from "../../cli/progress.js"; -import { renderTable } from "../../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { isRich, theme } from "../../terminal/theme.js"; import { groupChannelIssuesByChannel } from "./channel-issues.js"; import { appendStatusAllDiagnosis } from "./diagnosis.js"; @@ -57,7 +57,7 @@ export async function buildStatusAllReportLines(params: { const fail = (text: string) => (rich ? theme.error(text) : text); const muted = (text: string) => (rich ? theme.muted(text) : text); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const overview = renderTable({ width: tableWidth, diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 0d412c9715a..7e68424c5a9 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -16,7 +16,7 @@ import { } from "../memory/status-format.js"; import type { RuntimeEnv } from "../runtime.js"; import { runSecurityAudit } from "../security/audit.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { formatHealthChannelLines, type HealthSummary } from "./health.js"; import { resolveControlUiLinks } from "./onboard-helpers.js"; @@ -229,7 +229,7 @@ export async function statusCommand( runtime.log(""); } - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); if (secretDiagnostics.length > 0) { runtime.log(theme.warn("Secret diagnostics:")); From f46913b83497bcb29888575ef2268865c42bd56c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 11 Mar 2026 01:40:15 -0400 Subject: [PATCH 10/48] Skills: use Terminal-safe emoji in list output --- skills/eightctl/SKILL.md | 2 +- skills/gemini/SKILL.md | 2 +- skills/openai-image-gen/SKILL.md | 2 +- skills/openai-whisper-api/SKILL.md | 2 +- skills/openai-whisper/SKILL.md | 2 +- skills/sag/SKILL.md | 2 +- skills/sherpa-onnx-tts/SKILL.md | 2 +- skills/video-frames/SKILL.md | 2 +- skills/weather/SKILL.md | 2 +- skills/xurl/SKILL.md | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/skills/eightctl/SKILL.md b/skills/eightctl/SKILL.md index c3df81f628c..80a5f1f4bbb 100644 --- a/skills/eightctl/SKILL.md +++ b/skills/eightctl/SKILL.md @@ -6,7 +6,7 @@ metadata: { "openclaw": { - "emoji": "๐ŸŽ›๏ธ", + "emoji": "๐Ÿ›Œ", "requires": { "bins": ["eightctl"] }, "install": [ diff --git a/skills/gemini/SKILL.md b/skills/gemini/SKILL.md index 70850a4c522..f573afd6ba6 100644 --- a/skills/gemini/SKILL.md +++ b/skills/gemini/SKILL.md @@ -6,7 +6,7 @@ metadata: { "openclaw": { - "emoji": "โ™Š๏ธ", + "emoji": "โœจ", "requires": { "bins": ["gemini"] }, "install": [ diff --git a/skills/openai-image-gen/SKILL.md b/skills/openai-image-gen/SKILL.md index 5db45c2c0e5..5b12671b0b0 100644 --- a/skills/openai-image-gen/SKILL.md +++ b/skills/openai-image-gen/SKILL.md @@ -6,7 +6,7 @@ metadata: { "openclaw": { - "emoji": "๐Ÿ–ผ๏ธ", + "emoji": "๐ŸŽจ", "requires": { "bins": ["python3"], "env": ["OPENAI_API_KEY"] }, "primaryEnv": "OPENAI_API_KEY", "install": diff --git a/skills/openai-whisper-api/SKILL.md b/skills/openai-whisper-api/SKILL.md index 798b679e3ea..c961f132f4c 100644 --- a/skills/openai-whisper-api/SKILL.md +++ b/skills/openai-whisper-api/SKILL.md @@ -6,7 +6,7 @@ metadata: { "openclaw": { - "emoji": "โ˜๏ธ", + "emoji": "๐ŸŒ", "requires": { "bins": ["curl"], "env": ["OPENAI_API_KEY"] }, "primaryEnv": "OPENAI_API_KEY", }, diff --git a/skills/openai-whisper/SKILL.md b/skills/openai-whisper/SKILL.md index 1c9411a3ff6..c22e0d62252 100644 --- a/skills/openai-whisper/SKILL.md +++ b/skills/openai-whisper/SKILL.md @@ -6,7 +6,7 @@ metadata: { "openclaw": { - "emoji": "๐ŸŽ™๏ธ", + "emoji": "๐ŸŽค", "requires": { "bins": ["whisper"] }, "install": [ diff --git a/skills/sag/SKILL.md b/skills/sag/SKILL.md index a12e8a6d628..f0f7047651c 100644 --- a/skills/sag/SKILL.md +++ b/skills/sag/SKILL.md @@ -6,7 +6,7 @@ metadata: { "openclaw": { - "emoji": "๐Ÿ—ฃ๏ธ", + "emoji": "๐Ÿ”Š", "requires": { "bins": ["sag"], "env": ["ELEVENLABS_API_KEY"] }, "primaryEnv": "ELEVENLABS_API_KEY", "install": diff --git a/skills/sherpa-onnx-tts/SKILL.md b/skills/sherpa-onnx-tts/SKILL.md index 1628660637b..46f7ead58da 100644 --- a/skills/sherpa-onnx-tts/SKILL.md +++ b/skills/sherpa-onnx-tts/SKILL.md @@ -5,7 +5,7 @@ metadata: { "openclaw": { - "emoji": "๐Ÿ—ฃ๏ธ", + "emoji": "๐Ÿ”‰", "os": ["darwin", "linux", "win32"], "requires": { "env": ["SHERPA_ONNX_RUNTIME_DIR", "SHERPA_ONNX_MODEL_DIR"] }, "install": diff --git a/skills/video-frames/SKILL.md b/skills/video-frames/SKILL.md index 0aca9fbd199..93a550a6fc9 100644 --- a/skills/video-frames/SKILL.md +++ b/skills/video-frames/SKILL.md @@ -6,7 +6,7 @@ metadata: { "openclaw": { - "emoji": "๐ŸŽž๏ธ", + "emoji": "๐ŸŽฌ", "requires": { "bins": ["ffmpeg"] }, "install": [ diff --git a/skills/weather/SKILL.md b/skills/weather/SKILL.md index 3daedf90f25..8d463be0b6a 100644 --- a/skills/weather/SKILL.md +++ b/skills/weather/SKILL.md @@ -2,7 +2,7 @@ name: weather description: "Get current weather and forecasts via wttr.in or Open-Meteo. Use when: user asks about weather, temperature, or forecasts for any location. NOT for: historical weather data, severe weather alerts, or detailed meteorological analysis. No API key needed." homepage: https://wttr.in/:help -metadata: { "openclaw": { "emoji": "๐ŸŒค๏ธ", "requires": { "bins": ["curl"] } } } +metadata: { "openclaw": { "emoji": "โ˜”", "requires": { "bins": ["curl"] } } } --- # Weather Skill diff --git a/skills/xurl/SKILL.md b/skills/xurl/SKILL.md index cf76bf158ad..1d74d6de3ee 100644 --- a/skills/xurl/SKILL.md +++ b/skills/xurl/SKILL.md @@ -5,7 +5,7 @@ metadata: { "openclaw": { - "emoji": "๐•", + "emoji": "๐Ÿฆ", "requires": { "bins": ["xurl"] }, "install": [ From ad7db1cc0619452ef8dabb30e7b156a1f3963ae6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 11 Mar 2026 09:05:20 -0400 Subject: [PATCH 11/48] Changelog: note terminal skills table fixes --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index adc8b8d6e16..992cbc7de4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,7 @@ Docs: https://docs.openclaw.ai - MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent. - Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution. - Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey. +- 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. ## 2026.3.7 From accabda65c75badcda49e04fab9421741ea222eb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 11 Mar 2026 09:11:20 -0400 Subject: [PATCH 12/48] Skills: normalize emoji presentation across outputs --- src/cli/skills-cli.format.ts | 12 ++++++++---- src/cli/skills-cli.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts index dc335fb6c21..580f17b2d40 100644 --- a/src/cli/skills-cli.format.ts +++ b/src/cli/skills-cli.format.ts @@ -38,8 +38,12 @@ function formatSkillStatus(skill: SkillStatusEntry): string { return theme.error("โœ— missing"); } +function normalizeSkillEmoji(emoji?: string): string { + return (emoji ?? "๐Ÿ“ฆ").replaceAll("\uFE0E", "\uFE0F"); +} + function formatSkillName(skill: SkillStatusEntry): string { - const emoji = (skill.emoji ?? "๐Ÿ“ฆ").replaceAll("\uFE0E", "\uFE0F"); + const emoji = normalizeSkillEmoji(skill.emoji); return `${emoji} ${theme.command(skill.name)}`; } @@ -154,7 +158,7 @@ export function formatSkillInfo( } const lines: string[] = []; - const emoji = skill.emoji ?? "๐Ÿ“ฆ"; + const emoji = normalizeSkillEmoji(skill.emoji); const status = skill.eligible ? theme.success("โœ“ Ready") : skill.disabled @@ -282,7 +286,7 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp lines.push(""); lines.push(theme.heading("Ready to use:")); for (const skill of eligible) { - const emoji = skill.emoji ?? "๐Ÿ“ฆ"; + const emoji = normalizeSkillEmoji(skill.emoji); lines.push(` ${emoji} ${skill.name}`); } } @@ -291,7 +295,7 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp lines.push(""); lines.push(theme.heading("Missing requirements:")); for (const skill of missingReqs) { - const emoji = skill.emoji ?? "๐Ÿ“ฆ"; + const emoji = normalizeSkillEmoji(skill.emoji); const missing = formatSkillMissingSummary(skill); lines.push(` ${emoji} ${skill.name} ${theme.muted(`(${missing})`)}`); } diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index 37323e7f21d..e87f8b2d313 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -148,6 +148,18 @@ describe("skills-cli", () => { expect(output).toContain("Any binaries"); expect(output).toContain("API_KEY"); }); + + it("normalizes text-presentation emoji selectors in info output", () => { + const report = createMockReport([ + createMockSkill({ + name: "info-emoji", + emoji: "๐ŸŽ›\uFE0E", + }), + ]); + + const output = formatSkillInfo(report, "info-emoji", {}); + expect(output).toContain("๐ŸŽ›๏ธ"); + }); }); describe("formatSkillsCheck", () => { @@ -170,6 +182,22 @@ describe("skills-cli", () => { expect(output).toContain("go"); // missing binary expect(output).toContain("npx clawhub"); }); + + it("normalizes text-presentation emoji selectors in check output", () => { + const report = createMockReport([ + createMockSkill({ name: "ready-emoji", emoji: "๐ŸŽ›\uFE0E", eligible: true }), + createMockSkill({ + name: "missing-emoji", + emoji: "๐ŸŽ™\uFE0E", + eligible: false, + missing: { bins: ["ffmpeg"], anyBins: [], env: [], config: [], os: [] }, + }), + ]); + + const output = formatSkillsCheck(report, {}); + expect(output).toContain("๐ŸŽ›๏ธ ready-emoji"); + expect(output).toContain("๐ŸŽ™๏ธ missing-emoji"); + }); }); describe("JSON output", () => { From 361f3109a50a7029cf26e10ae283cd809cccb8ed Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 11 Mar 2026 09:11:25 -0400 Subject: [PATCH 13/48] Terminal: consume unsupported escape bytes in tables --- src/terminal/table.test.ts | 14 ++++++++++++++ src/terminal/table.ts | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/src/terminal/table.test.ts b/src/terminal/table.test.ts index f6efea97609..9c6d53eaece 100644 --- a/src/terminal/table.test.ts +++ b/src/terminal/table.test.ts @@ -156,6 +156,20 @@ describe("renderTable", () => { expect(visibleWidth(line)).toBe(width); } }); + + it("consumes unsupported escape sequences without hanging", () => { + const out = renderTable({ + width: 48, + columns: [ + { key: "K", header: "K", minWidth: 6 }, + { key: "V", header: "V", minWidth: 12, flex: true }, + ], + rows: [{ K: "row", V: "before \x1b[2J after" }], + }); + + expect(out).toContain("before"); + expect(out).toContain("after"); + }); }); describe("wrapNoteMessage", () => { diff --git a/src/terminal/table.ts b/src/terminal/table.ts index 2945e47019c..a1fbb9f570b 100644 --- a/src/terminal/table.ts +++ b/src/terminal/table.ts @@ -98,6 +98,13 @@ function wrapLine(text: string, width: number): string[] { if (nextEsc < 0) { nextEsc = text.length; } + if (nextEsc === i) { + // Consume unsupported escape bytes as plain characters so wrapping + // cannot stall on unknown ANSI/control sequences. + tokens.push({ kind: "char", value: ESC }); + i += ESC.length; + continue; + } const plainChunk = text.slice(i, nextEsc); for (const grapheme of splitGraphemes(plainChunk)) { tokens.push({ kind: "char", value: grapheme }); From 29dc65403faf41dc52944c02a0db9fa4b8457395 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 12 Mar 2026 05:01:01 +0000 Subject: [PATCH 14/48] build: prepare 2026.3.11 release --- CHANGELOG.md | 6 ++++++ apps/android/app/build.gradle.kts | 2 +- apps/macos/Sources/OpenClaw/Resources/Info.plist | 2 +- package.json | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6983b27cc6e..93fedbb94de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Docs: https://docs.openclaw.ai ## Unreleased +### Changes + +### Fixes + +## 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) 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/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index 8605ee1264d..4a6f9003f75 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 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", From 18f15850e6de4b2d9fc88f995614a25a3404abc1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 01:04:31 -0400 Subject: [PATCH 15/48] fix(browser): restore proxy attachment media size cap (#43684) * browser: honor shared proxy file size cap * test(browser): cover proxy file size cap * docs(changelog): note browser proxy size cap fix --- CHANGELOG.md | 1 + src/browser/proxy-files.test.ts | 54 +++++++++++++++++++++++++++++++++ src/browser/proxy-files.ts | 2 +- 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/browser/proxy-files.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fedbb94de..e173482f1e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,6 +130,7 @@ Docs: https://docs.openclaw.ai - Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc. - Dependencies: refresh workspace dependencies except the pinned Carbon package, and harden ACP session-config writes against non-string SDK values so newer ACP clients fail fast instead of tripping type/runtime mismatches. - Telegram/polling restarts: clear bounded cleanup timeout handles after `runner.stop()` and `bot.stop()` settle so stall recovery no longer leaves stray 15-second timers behind on clean shutdown. (#43188) thanks @kyohwang. +- Browser/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. (#43684) Thanks @vincentkoc. ## 2026.3.8 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; From 1dcef7b644524c3932e2bc269f969cfada8cfdcf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 01:16:03 -0400 Subject: [PATCH 16/48] Infra: block GIT_EXEC_PATH in host env sanitizer (#43685) * Infra: block GIT_EXEC_PATH in host env sanitizer * Changelog: note host env hardening --- CHANGELOG.md | 1 + .../HostEnvSecurityPolicy.generated.swift | 1 + src/infra/host-env-security-policy.json | 1 + src/infra/host-env-security.test.ts | 57 +++++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e173482f1e1..14e3e890270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -129,6 +129,7 @@ Docs: https://docs.openclaw.ai - Agents/fallback cooldown probing: cap cooldown-bypass probing to one attempt per provider per fallback run so multi-model same-provider cooldown chains can continue to cross-provider fallbacks instead of repeatedly stalling on duplicate cooldown probes. (#41711) Thanks @cgdusek. - Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc. - Dependencies: refresh workspace dependencies except the pinned Carbon package, and harden ACP session-config writes against non-string SDK values so newer ACP clients fail fast instead of tripping type/runtime mismatches. +- Security/host env: block inherited `GIT_EXEC_PATH` from sanitized host exec environments so Git helper resolution cannot be steered by host environment state. (#43685) Thanks @vincentkoc. - Telegram/polling restarts: clear bounded cleanup timeout handles after `runner.stop()` and `bot.stop()` settle so stall recovery no longer leaves stray 15-second timers behind on clean shutdown. (#43188) thanks @kyohwang. - Browser/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. (#43684) Thanks @vincentkoc. 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/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..87156c10396 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,60 @@ 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 { + 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); + }); + it("blocks GIT_SSH_COMMAND override so git cannot execute helper payloads", async () => { if (process.platform === "win32") { return; From 2504cb6a1e0dc8db9a52428945b9294174b69232 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 01:20:04 -0400 Subject: [PATCH 17/48] Security: escape invisible exec approval format chars (#43687) * Infra: escape invisible exec approval chars * Gateway: sanitize exec approval display text * Tests: cover sanitized exec approval payloads * Tests: cover sanitized exec approval forwarding * Changelog: note exec approval prompt hardening --- CHANGELOG.md | 3 ++ src/gateway/server-methods/exec-approval.ts | 8 +++- .../server-methods/server-methods.test.ts | 28 ++++++++++++++ src/infra/exec-approval-command-display.ts | 38 ++++++++++++------- src/infra/exec-approval-forwarder.test.ts | 18 +++++++++ 5 files changed, 80 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14e3e890270..7bc48b65841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Docs: https://docs.openclaw.ai ## Unreleased +### 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. (#43687) Thanks @EkiXu and @vincentkoc. + ### Changes ### Fixes 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/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 }); From 4f462facda1828942587c44fc876b069a6d01006 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 01:25:52 -0400 Subject: [PATCH 18/48] Infra: cap device tokens to approved scopes (#43686) * Infra: cap device tokens to approved scopes * Changelog: note device token hardening --- CHANGELOG.md | 1 + src/infra/device-pairing.test.ts | 53 +++++++++++++++++++++++++++++++- src/infra/device-pairing.ts | 18 ++++++++++- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bc48b65841..85d61df698d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ 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. (#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. (#43686) Thanks @tdjackey and @vincentkoc. ### Changes diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index c76b44b323d..915e06bb9c6 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,21 @@ 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)); +} + 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 +198,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 +237,19 @@ 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("accepts operator.read/operator.write requests with an operator.admin token scope", async () => { const { baseDir, token } = await setupOperatorToken(["operator.admin"]); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 591a9d70888..9d994a308f2 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -494,6 +494,12 @@ export async function verifyDeviceToken(params: { if (!verifyPairingToken(params.token, entry.token)) { return { ok: false, reason: "token-mismatch" }; } + const approvedScopes = normalizeDeviceAuthScopes( + device.approvedScopes ?? device.scopes ?? entry.scopes, + ); + if (!scopesAllowWithImplications(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 +531,18 @@ export async function ensureDeviceToken(params: { return null; } const { device, role, tokens, existing } = context; + const approvedScopes = normalizeDeviceAuthScopes( + device.approvedScopes ?? device.scopes ?? existing?.scopes, + ); + if (!scopesAllowWithImplications(requestedScopes, approvedScopes)) { + return null; + } if (existing && !existing.revokedAtMs) { - if (roleScopesAllow({ role, requestedScopes, allowedScopes: existing.scopes })) { + const existingWithinApproved = scopesAllowWithImplications(existing.scopes, approvedScopes); + if ( + existingWithinApproved && + roleScopesAllow({ role, requestedScopes, allowedScopes: existing.scopes }) + ) { return existing; } } From 672924b01e10d133bb15c9a3fd1619eeeb8827d2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 01:36:16 -0400 Subject: [PATCH 19/48] Update CHANGELOG.md --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85d61df698d..26d4f880070 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ 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. (#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. (#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. (#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. (#43685) Thanks @zpbrent and @vincentkoc. ### Changes @@ -133,9 +135,7 @@ Docs: https://docs.openclaw.ai - Agents/fallback cooldown probing: cap cooldown-bypass probing to one attempt per provider per fallback run so multi-model same-provider cooldown chains can continue to cross-provider fallbacks instead of repeatedly stalling on duplicate cooldown probes. (#41711) Thanks @cgdusek. - Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc. - Dependencies: refresh workspace dependencies except the pinned Carbon package, and harden ACP session-config writes against non-string SDK values so newer ACP clients fail fast instead of tripping type/runtime mismatches. -- Security/host env: block inherited `GIT_EXEC_PATH` from sanitized host exec environments so Git helper resolution cannot be steered by host environment state. (#43685) Thanks @vincentkoc. - Telegram/polling restarts: clear bounded cleanup timeout handles after `runner.stop()` and `bot.stop()` settle so stall recovery no longer leaves stray 15-second timers behind on clean shutdown. (#43188) thanks @kyohwang. -- Browser/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. (#43684) Thanks @vincentkoc. ## 2026.3.8 From 99a5a3c16a39128e78baf4bf1b258fecc1f59c4c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 01:37:33 -0400 Subject: [PATCH 20/48] Update CHANGELOG.md --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d4f880070..4808c960c16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,10 @@ Docs: https://docs.openclaw.ai ## Unreleased ### 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. (#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. (#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. (#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. (#43685) Thanks @zpbrent and @vincentkoc. +- 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. ### Changes From 276ee259ca2d876ee02731f956546a4b08dbd0b3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 01:39:53 -0400 Subject: [PATCH 21/48] Tests: clean up temp git helper directory --- src/infra/host-env-security.test.ts | 69 ++++++++++++++++------------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index 87156c10396..08f1a3d65fb 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -232,40 +232,45 @@ describe("git env exploit regression", () => { `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); - } catch { - // no-op + + 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 }); } - 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); }); it("blocks GIT_SSH_COMMAND override so git cannot execute helper payloads", async () => { From d8d8dc7421885c984e23a9e794a29972ff9c56d2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 01:41:13 -0400 Subject: [PATCH 22/48] Infra: fail closed without device scope baseline --- src/infra/device-pairing.test.ts | 86 ++++++++++++++++++++++++++ src/infra/device-pairing.ts | 103 ++++++++++++++++--------------- 2 files changed, 138 insertions(+), 51 deletions(-) diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 915e06bb9c6..17f03df089a 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -69,6 +69,28 @@ async function overwritePairedOperatorTokenScopes(baseDir: string, scopes: strin 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-")); @@ -250,6 +272,19 @@ describe("device pairing tokens", () => { ).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"]); @@ -268,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 9d994a308f2..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,10 +479,14 @@ export async function verifyDeviceToken(params: { if (!verifyPairingToken(params.token, entry.token)) { return { ok: false, reason: "token-mismatch" }; } - const approvedScopes = normalizeDeviceAuthScopes( - device.approvedScopes ?? device.scopes ?? entry.scopes, - ); - if (!scopesAllowWithImplications(entry.scopes, approvedScopes)) { + const approvedScopes = resolveApprovedDeviceScopeBaseline(device); + if ( + !scopesWithinApprovedDeviceBaseline({ + role, + scopes: entry.scopes, + approvedScopes, + }) + ) { return { ok: false, reason: "scope-mismatch" }; } const requestedScopes = normalizeDeviceAuthScopes(params.scopes); @@ -531,14 +520,22 @@ export async function ensureDeviceToken(params: { return null; } const { device, role, tokens, existing } = context; - 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; } if (existing && !existing.revokedAtMs) { - const existingWithinApproved = scopesAllowWithImplications(existing.scopes, approvedScopes); + const existingWithinApproved = scopesWithinApprovedDeviceBaseline({ + role, + scopes: existing.scopes, + approvedScopes, + }); if ( existingWithinApproved && roleScopesAllow({ role, requestedScopes, allowedScopes: existing.scopes }) @@ -605,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(); From f7416da905cae6279e3ceeb4af9bdf329ab6ea32 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 12 Mar 2026 11:28:27 +0530 Subject: [PATCH 23/48] style: format changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4808c960c16..05cef60789f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Docs: https://docs.openclaw.ai ## Unreleased ### 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. From cee87170202ca1d3d78a4148d4908a41137e5d6d Mon Sep 17 00:00:00 2001 From: Dinakar Sarbada Date: Thu, 19 Feb 2026 10:29:34 -0800 Subject: [PATCH 24/48] fix(macos): add NSRemindersUsageDescription for apple-reminders skill Fixes #5090 Without this plist key, macOS silently denies Reminders access when running through OpenClaw.app, preventing the apple-reminders skill from requesting permission. (cherry picked from commit e5774471c851b773dd2bffd51dd5d28d95a8a7ca) --- apps/macos/Sources/OpenClaw/Resources/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index 4a6f9003f75..0bfd45cc97b 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -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 From 8baf55d8edff26c9e5a9a8416b659b23fb9e2957 Mon Sep 17 00:00:00 2001 From: Luke Date: Thu, 12 Mar 2026 17:01:42 +1100 Subject: [PATCH 25/48] Changelog: note Reminders permission fix --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05cef60789f..b1e6f4e2a20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- 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. + ## 2026.3.11 ### Security From 12dc299cdef3759486b52033e78b550d985c3371 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 02:27:35 -0400 Subject: [PATCH 26/48] fix(imessage): dedupe reflected self-chat duplicates (#38440) * iMessage: drop reflected self-chat duplicates * Changelog: add iMessage self-chat echo dedupe entry * iMessage: keep self-chat dedupe scoped to final group identity * iMessage: harden self-chat cache * iMessage: sanitize self-chat duplicate logs * iMessage: scope group self-chat dedupe by sender * iMessage: move self-chat cache identity into cache * iMessage: hash full self-chat text * Update CHANGELOG.md --- CHANGELOG.md | 1 + .../monitor/inbound-processing.test.ts | 320 ++++++++++++++++++ src/imessage/monitor/inbound-processing.ts | 32 +- src/imessage/monitor/monitor-provider.ts | 4 + src/imessage/monitor/self-chat-cache.test.ts | 76 +++++ src/imessage/monitor/self-chat-cache.ts | 103 ++++++ 6 files changed, 531 insertions(+), 5 deletions(-) create mode 100644 src/imessage/monitor/self-chat-cache.test.ts create mode 100644 src/imessage/monitor/self-chat-cache.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e6f4e2a20..13f03d5f9a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes - 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. ## 2026.3.11 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(); +} From 99ec687d7a5e015c1fa777058b6c0abdf7abdee0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 02:54:25 -0400 Subject: [PATCH 27/48] fix(agents): enforce sandboxed session_status visibility (#43754) * agents: guard sandboxed session_status access * test(agents): cover sandboxed session_status scope * docs(changelog): credit session_status hardening * agents: preflight sandboxed session_status checks * test(agents): cover session_status existence oracle * agents: preserve legacy session_status tree keys * test(agents): cover legacy session_status tree keys * Update CHANGELOG.md --- CHANGELOG.md | 2 +- .../openclaw-tools.session-status.test.ts | 189 ++++++++++++++++-- src/agents/openclaw-tools.ts | 1 + src/agents/tools/session-status-tool.ts | 88 +++++++- src/agents/tools/sessions-access.ts | 14 +- 5 files changed, 271 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13f03d5f9a4..457fb8fac39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - 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. ### Changes @@ -102,7 +103,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. 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.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/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."; } From f3c00fce157867d3aadd4041e578bbb40b4841d0 Mon Sep 17 00:00:00 2001 From: lisitan <50470712+lisitan@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:59:42 +0800 Subject: [PATCH 28/48] fix: prevent duplicate assistant messages in TUI (fixes #35278) (#35364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent duplicate assistant messages in TUI (fixes #35278) When startAssistant() is called multiple times with the same runId, it was creating duplicate AssistantMessageComponent instances instead of reusing the existing one. This caused messages to appear twice in the terminal UI. The fix checks if a component already exists for the runId before creating a new one. If it exists, we update its text instead of appending a duplicate component. Test coverage includes verification that: - Only one component is created when startAssistant is called twice - The second text replaces the first - Component count remains 1 (prevents regression) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * Changelog: add TUI duplicate-render fix entry --------- Co-authored-by: ๆฒๆฒ Co-authored-by: Claude Co-authored-by: Happy Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + src/tui/components/chat-log.test.ts | 11 +++++++++++ src/tui/components/chat-log.ts | 8 +++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 457fb8fac39..9bd2517e4c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- 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. 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; } From 6c196c913fc3fde51f3e2e1087ed94a6920fac1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?wangchunyue=28=E7=8E=8B=E6=98=A5=E8=B7=83=29?= <80630709+openperf@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:01:19 +0800 Subject: [PATCH 29/48] fix(cron): prevent duplicate proactive delivery on transient retry (#40646) * fix(cron): prevent duplicate proactive delivery on transient retry * refactor: scope skipQueue to retryTransient path only Non-retrying direct delivery (structured content / thread) keeps the write-ahead queue so recoverPendingDeliveries can replay after a crash. Addresses review feedback from codex-connector. * fix: preserve write-ahead queue on initial delivery attempt The first call through retryTransientDirectCronDelivery now keeps the write-ahead queue entry so recoverPendingDeliveries can replay after a crash. Only subsequent retry attempts set skipQueue to prevent duplicate sends. Addresses second codex-connector review on ea5ae5c. * ci: retrigger checks * Cron: bypass write-ahead queue for direct isolated delivery * Tests: assert isolated cron skipQueue invariants * Changelog: add cron duplicate-delivery fix entry --------- Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + .../delivery-dispatch.double-announce.test.ts | 68 +++++++++++++++++++ src/cron/isolated-agent/delivery-dispatch.ts | 10 ++- 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bd2517e4c8..029b1fa2024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### 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. 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({ From 241e8cc553c0ab915935be0295c9bd60d0316d14 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 03:11:43 -0400 Subject: [PATCH 30/48] fix(bluebubbles): dedupe reflected self-chat duplicates (#38442) * BlueBubbles: drop reflected self-chat duplicates * Changelog: add BlueBubbles self-chat echo dedupe entry * BlueBubbles: gate self-chat cache and expand coverage * BlueBubbles: require explicit sender ids for self-chat dedupe * BlueBubbles: harden self-chat cache * BlueBubbles: move self-chat cache identity into cache * BlueBubbles: gate self-chat cache to confirmed outbound sends * Update CHANGELOG.md * BlueBubbles: bound self-chat cache input work * Tests: cover BlueBubbles cache cap under cleanup throttle * BlueBubbles: canonicalize self-chat DM scope * Tests: cover BlueBubbles mixed self-chat scope aliases --- CHANGELOG.md | 1 + .../bluebubbles/src/monitor-normalize.test.ts | 20 + .../bluebubbles/src/monitor-normalize.ts | 22 +- .../bluebubbles/src/monitor-processing.ts | 42 +- .../src/monitor-self-chat-cache.test.ts | 190 +++++++++ .../src/monitor-self-chat-cache.ts | 127 ++++++ extensions/bluebubbles/src/monitor.test.ts | 361 ++++++++++++++++++ 7 files changed, 756 insertions(+), 7 deletions(-) create mode 100644 extensions/bluebubbles/src/monitor-self-chat-cache.test.ts create mode 100644 extensions/bluebubbles/src/monitor-self-chat-cache.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 029b1fa2024..7dd8ba5adc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - 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. +- 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. ## 2026.3.11 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..71f420ef70d 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,6 +475,16 @@ 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(); let messageShortId: string | undefined; @@ -485,6 +517,9 @@ export async function processMessage( body: rawBody, }); if (pending) { + if (isSelfChatMessage) { + rememberBlueBubblesSelfChatCopy(selfChatLookup); + } const displayId = getShortIdForUuid(cacheMessageId) || cacheMessageId; const previewSource = pending.snippetRaw || rawBody; const preview = previewSource @@ -499,6 +534,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..7b76e659c3f 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,363 @@ 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: "ok" }); + + 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 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(); + }); }); }); From e8a162d3d8a0643416098f612108c3546efd8733 Mon Sep 17 00:00:00 2001 From: Mathias Nagler Date: Thu, 12 Mar 2026 08:15:17 +0100 Subject: [PATCH 31/48] fix(mattermost): prevent duplicate messages when block streaming + threading are active (#41362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mattermost): prevent duplicate messages when block streaming + threading are active Remove replyToId from createBlockReplyPayloadKey so identical content is deduplicated regardless of threading target. Add explicit threading dock to the Mattermost plugin with resolveReplyToMode reading from config (default "all"), and add replyToMode to the Mattermost config schema. Fixes #41219 Co-Authored-By: Claude Opus 4.6 * fix(mattermost): address PR review โ€” per-account replyToMode and test clarity Read replyToMode from the merged per-account config via resolveMattermostAccount so account-level overrides are honored in multi-account setups. Add replyToMode to MattermostAccountConfig type. Rename misleading test to clarify it exercises shouldDropFinalPayloads short-circuit, not payload key dedup. Co-Authored-By: Claude Opus 4.6 * Replies: keep block-pipeline reply targets distinct * Tests: cover block reply target-aware dedupe * Update CHANGELOG.md --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + extensions/mattermost/src/channel.ts | 10 +++ extensions/mattermost/src/config-schema.ts | 1 + .../mattermost/src/mattermost/monitor.test.ts | 23 ++++++ extensions/mattermost/src/types.ts | 2 + .../reply/agent-runner-payloads.test.ts | 44 +++++++++++ src/auto-reply/reply/agent-runner-payloads.ts | 4 +- .../reply/block-reply-pipeline.test.ts | 79 +++++++++++++++++++ src/auto-reply/reply/block-reply-pipeline.ts | 20 ++++- src/auto-reply/reply/reply-delivery.ts | 4 +- 10 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 src/auto-reply/reply/block-reply-pipeline.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd8ba5adc8..fb7c3ba9402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - 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. ## 2026.3.11 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/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. From 0bcb95e8fa5e93d2c6b19a79e9d27075efa8a48a Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:22:52 -0500 Subject: [PATCH 32/48] Models: enforce source-managed SecretRef markers in models.json (#43759) Merged via squash. Prepared head SHA: 4a065ef5d849273756ceb0dd241ca24ca9e621ca Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com> Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com> Reviewed-by: @joshavant --- CHANGELOG.md | 1 + docs/cli/agent.md | 3 +- docs/concepts/models.md | 6 +- docs/gateway/configuration-reference.md | 2 + .../reference/secretref-credential-surface.md | 1 + src/agents/models-config.plan.ts | 13 +- ...ls-config.providers.normalize-keys.test.ts | 39 +++- src/agents/models-config.providers.ts | 172 ++++++++++++++++-- ...els-config.runtime-source-snapshot.test.ts | 148 +++++++++++++++ src/agents/models-config.ts | 28 ++- 10 files changed, 390 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb7c3ba9402..ce7e683df38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - 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. ### Changes 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/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, From 4dfd8eea903da710c95caada1a6af293e0e461c5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 03:22:42 -0400 Subject: [PATCH 33/48] BlueBubbles: require confirmed outbound for self-chat cache --- .../bluebubbles/src/monitor-processing.ts | 18 +++- extensions/bluebubbles/src/monitor.test.ts | 88 ++++++++++++++++++- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 71f420ef70d..9cf72ea1efd 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -487,6 +487,15 @@ export async function processMessage( }; 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) { @@ -508,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, @@ -517,9 +532,6 @@ export async function processMessage( body: rawBody, }); if (pending) { - if (isSelfChatMessage) { - rememberBlueBubblesSelfChatCopy(selfChatLookup); - } const displayId = getShortIdForUuid(cacheMessageId) || cacheMessageId; const previewSource = pending.snippetRaw || rawBody; const preview = previewSource diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 7b76e659c3f..1ba2e27f0b6 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -2687,7 +2687,7 @@ describe("BlueBubbles webhook monitor", () => { setBlueBubblesRuntime(core); const { sendMessageBlueBubbles } = await import("./send.js"); - vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" }); + vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "msg-self-1" }); mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); @@ -2980,6 +2980,92 @@ describe("BlueBubbles webhook monitor", () => { 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 = {}; From d8ee97c4668d565f05aeaefdf989fa8e8dfc97a0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 03:28:22 -0400 Subject: [PATCH 34/48] Agents: recover malformed Anthropic-compatible tool call args (#42835) * Agents: recover malformed anthropic tool call args * Agents: add malformed tool call regression test * Changelog: note Kimi tool call arg recovery * Agents: repair toolcall end message snapshots * Agents: narrow Kimi tool call arg repair --- CHANGELOG.md | 1 + .../pi-embedded-runner/run/attempt.test.ts | 132 +++++++++ src/agents/pi-embedded-runner/run/attempt.ts | 261 ++++++++++++++++++ 3 files changed, 394 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce7e683df38..39da7caaf37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - 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. ## 2026.3.11 diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 9821adc0e0b..33a4f9654df 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,137 @@ 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({}); + }); +}); + 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..790323b8232 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -436,6 +436,258 @@ 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 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`, + ); + } + } + } + } + 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 +1631,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, From 82e3ac21eec9a914015397d7fc8370dab9786c3d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 03:33:50 -0400 Subject: [PATCH 35/48] Infra: tighten exec allowlist glob matching (#43798) * Infra: tighten exec allowlist glob matching * Changelog: note GHSA-f8r2 exec allowlist fix --- CHANGELOG.md | 1 + src/infra/exec-allowlist-pattern.test.ts | 14 ++++++++++++++ src/infra/exec-allowlist-pattern.ts | 6 +++--- 3 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 src/infra/exec-allowlist-pattern.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 39da7caaf37..ee68ed4948d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - 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/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 diff --git a/src/infra/exec-allowlist-pattern.test.ts b/src/infra/exec-allowlist-pattern.test.ts new file mode 100644 index 00000000000..2c45e12627f --- /dev/null +++ b/src/infra/exec-allowlist-pattern.test.ts @@ -0,0 +1,14 @@ +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.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); + }); +}); diff --git a/src/infra/exec-allowlist-pattern.ts b/src/infra/exec-allowlist-pattern.ts index df05a2ae1d9..cdf84dfc51e 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 { @@ -46,7 +46,7 @@ function compileGlobRegex(pattern: string): RegExp { continue; } if (ch === "?") { - regex += "."; + regex += "[^/]"; i += 1; continue; } @@ -55,7 +55,7 @@ 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(); } From ed0ec57a7bf6f471c18c5e5f088eba1f25574790 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 12 Mar 2026 13:14:17 +0530 Subject: [PATCH 36/48] fix: scope telegram polling restart to telegram errors (#43799) * fix: scope telegram polling restart to telegram errors * fix: make telegram error tagging best-effort * fix: scope telegram polling restart to telegram errors (#43799) --- CHANGELOG.md | 1 + src/telegram/bot.fetch-abort.test.ts | 88 +++++++++++++++++++++++----- src/telegram/bot.ts | 46 +++++++++++++++ src/telegram/monitor.test.ts | 55 ++++++++++++++++- src/telegram/monitor.ts | 10 +++- src/telegram/network-errors.test.ts | 25 ++++++++ src/telegram/network-errors.ts | 47 +++++++++++++++ 7 files changed, 251 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee68ed4948d..cf7a60071bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,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. 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 From c5ea6134d04120d77ed03a74ea3fd4f2859d3447 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:48:58 -0500 Subject: [PATCH 37/48] feat(ui): add chat infrastructure modules (slice 1/3 of dashboard-v2) (#41497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2) New self-contained chat modules extracted from dashboard-v2-structure: - chat/slash-commands.ts: slash command definitions and completions - chat/slash-command-executor.ts: execute slash commands via gateway RPC - chat/slash-command-executor.node.test.ts: test coverage - chat/speech.ts: speech-to-text (STT) support - chat/input-history.ts: per-session input history navigation - chat/pinned-messages.ts: pinned message management - chat/deleted-messages.ts: deleted message tracking - chat/export.ts: shared exportChatMarkdown helper - chat-export.ts: re-export shim for backwards compat Gateway fix: - Restore usage/cost stripping in chat.history sanitization - Add test coverage for sanitization behavior These modules are additive and tree-shaken โ€” no existing code imports them yet. They will be wired in subsequent slices. * Update ui/src/ui/chat/export.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix(ui): address review feedback on chat infra slice - export.ts: handle array content blocks (Claude API format) instead of silently exporting empty strings - slash-command-executor.ts: restrict /kill all to current session's subagent subtree instead of all sessions globally - slash-command-executor.ts: only count truly aborted runs (check aborted !== false) in /kill summary * fix: scope /kill to current session subtree and preserve usage.cost in chat.history - Restrict /kill matching to only subagents belonging to the current session's agent subtree (P1 review feedback) - Preserve nested usage.cost in chat.history sanitization so cost badges remain available (P2 review feedback) * fix(ui): tighten slash kill scoping * fix(ui): support legacy slash kill scopes * fix(ci): repair pr branch checks * Gateway: harden chat abort and export * UI: align slash commands with session tree scope * UI: resolve session aliases for slash command lookups * Update .gitignore * Cron: use shared nested lane resolver --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Vincent Koc --- src/config/types.gateway.ts | 2 + src/gateway/chat-abort.ts | 2 + .../chat.abort-authorization.test.ts | 147 +++++ src/gateway/server-methods/chat.ts | 223 ++++++- .../server.chat.gateway-server-chat-b.test.ts | 31 + src/gateway/session-utils.ts | 1 + src/gateway/session-utils.types.ts | 1 + ui/src/ui/chat-export.ts | 1 + ui/src/ui/chat/deleted-messages.ts | 49 ++ ui/src/ui/chat/export.node.test.ts | 38 ++ ui/src/ui/chat/export.ts | 68 +++ ui/src/ui/chat/input-history.ts | 49 ++ ui/src/ui/chat/pinned-messages.ts | 61 ++ .../chat/slash-command-executor.node.test.ts | 381 ++++++++++++ ui/src/ui/chat/slash-command-executor.ts | 545 ++++++++++++++++++ ui/src/ui/chat/slash-commands.node.test.ts | 26 + ui/src/ui/chat/slash-commands.ts | 222 +++++++ ui/src/ui/chat/speech.ts | 225 ++++++++ ui/src/ui/types.ts | 1 + 19 files changed, 2057 insertions(+), 16 deletions(-) create mode 100644 src/gateway/server-methods/chat.abort-authorization.test.ts create mode 100644 ui/src/ui/chat-export.ts create mode 100644 ui/src/ui/chat/deleted-messages.ts create mode 100644 ui/src/ui/chat/export.node.test.ts create mode 100644 ui/src/ui/chat/export.ts create mode 100644 ui/src/ui/chat/input-history.ts create mode 100644 ui/src/ui/chat/pinned-messages.ts create mode 100644 ui/src/ui/chat/slash-command-executor.node.test.ts create mode 100644 ui/src/ui/chat/slash-command-executor.ts create mode 100644 ui/src/ui/chat/slash-commands.node.test.ts create mode 100644 ui/src/ui/chat/slash-commands.ts create mode 100644 ui/src/ui/chat/speech.ts 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/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/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.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/ui/src/ui/chat-export.ts b/ui/src/ui/chat-export.ts new file mode 100644 index 00000000000..ed5bbf931f8 --- /dev/null +++ b/ui/src/ui/chat-export.ts @@ -0,0 +1 @@ +export { exportChatMarkdown } from "./chat/export.ts"; diff --git a/ui/src/ui/chat/deleted-messages.ts b/ui/src/ui/chat/deleted-messages.ts new file mode 100644 index 00000000000..fd3916d78c7 --- /dev/null +++ b/ui/src/ui/chat/deleted-messages.ts @@ -0,0 +1,49 @@ +const PREFIX = "openclaw:deleted:"; + +export class DeletedMessages { + private key: string; + private _keys = new Set(); + + constructor(sessionKey: string) { + this.key = PREFIX + sessionKey; + this.load(); + } + + has(key: string): boolean { + return this._keys.has(key); + } + + delete(key: string): void { + this._keys.add(key); + this.save(); + } + + restore(key: string): void { + this._keys.delete(key); + this.save(); + } + + clear(): void { + this._keys.clear(); + this.save(); + } + + private load(): void { + try { + const raw = localStorage.getItem(this.key); + if (!raw) { + return; + } + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + this._keys = new Set(arr.filter((s) => typeof s === "string")); + } + } catch { + // ignore + } + } + + private save(): void { + localStorage.setItem(this.key, JSON.stringify([...this._keys])); + } +} diff --git a/ui/src/ui/chat/export.node.test.ts b/ui/src/ui/chat/export.node.test.ts new file mode 100644 index 00000000000..fa4bb428b3b --- /dev/null +++ b/ui/src/ui/chat/export.node.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { buildChatExportFilename, buildChatMarkdown, sanitizeFilenameComponent } from "./export.ts"; + +describe("chat export hardening", () => { + it("escapes raw HTML in exported markdown content and labels", () => { + const markdown = buildChatMarkdown( + [ + { + role: "assistant", + content: "", + timestamp: Date.UTC(2026, 2, 11, 12, 0, 0), + }, + ], + "Bot ", + ); + + expect(markdown).toContain( + "# Chat with Bot </script><script>alert(3)</script>", + ); + expect(markdown).toContain( + "## Bot </script><script>alert(3)</script> (2026-03-11T12:00:00.000Z)", + ); + expect(markdown).toContain( + "<img src=x onerror=alert(1)><script>alert(2)</script>", + ); + expect(markdown).not.toContain("")).toBe( + "NUL scriptalert1-script", + ); + expect(buildChatExportFilename("../NUL\t", 123)).toBe( + "chat-NUL scriptalert1-script-123.md", + ); + }); +}); diff --git a/ui/src/ui/chat/export.ts b/ui/src/ui/chat/export.ts new file mode 100644 index 00000000000..b42796f6a0a --- /dev/null +++ b/ui/src/ui/chat/export.ts @@ -0,0 +1,68 @@ +/** + * Export chat history as markdown file. + */ +export function escapeHtmlInMarkdown(text: string): string { + return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + +export function normalizeSingleLineLabel(label: string, fallback = "Assistant"): string { + const normalized = label.replace(/[\r\n\t]+/g, " ").trim(); + return normalized || fallback; +} + +export function sanitizeFilenameComponent(input: string): string { + const normalized = normalizeSingleLineLabel(input, "assistant").normalize("NFKC"); + const sanitized = normalized + .replace(/[\\/]/g, "-") + .replace(/[^a-zA-Z0-9 _.-]/g, "") + .replace(/\s+/g, " ") + .replace(/-+/g, "-") + .trim() + .replace(/^[.-]+/, "") + .slice(0, 50); + return sanitized || "assistant"; +} + +export function buildChatMarkdown(messages: unknown[], assistantNameRaw: string): string | null { + const assistantName = escapeHtmlInMarkdown(normalizeSingleLineLabel(assistantNameRaw)); + const history = Array.isArray(messages) ? messages : []; + if (history.length === 0) { + return null; + } + const lines: string[] = [`# Chat with ${assistantName}`, ""]; + for (const msg of history) { + const m = msg as Record; + const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool"; + const content = escapeHtmlInMarkdown( + typeof m.content === "string" + ? m.content + : Array.isArray(m.content) + ? (m.content as Array<{ type?: string; text?: string }>) + .filter((b) => b?.type === "text" && typeof b.text === "string") + .map((b) => b.text) + .join("") + : "", + ); + const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; + lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); + } + return lines.join("\n"); +} + +export function buildChatExportFilename(assistantNameRaw: string, now = Date.now()): string { + return `chat-${sanitizeFilenameComponent(assistantNameRaw)}-${now}.md`; +} + +export function exportChatMarkdown(messages: unknown[], assistantName: string): void { + const markdown = buildChatMarkdown(messages, assistantName); + if (!markdown) { + return; + } + const blob = new Blob([markdown], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = buildChatExportFilename(assistantName); + link.click(); + URL.revokeObjectURL(url); +} diff --git a/ui/src/ui/chat/input-history.ts b/ui/src/ui/chat/input-history.ts new file mode 100644 index 00000000000..34d8806d072 --- /dev/null +++ b/ui/src/ui/chat/input-history.ts @@ -0,0 +1,49 @@ +const MAX = 50; + +export class InputHistory { + private items: string[] = []; + private cursor = -1; + + push(text: string): void { + const trimmed = text.trim(); + if (!trimmed) { + return; + } + if (this.items[this.items.length - 1] === trimmed) { + return; + } + this.items.push(trimmed); + if (this.items.length > MAX) { + this.items.shift(); + } + this.cursor = -1; + } + + up(): string | null { + if (this.items.length === 0) { + return null; + } + if (this.cursor < 0) { + this.cursor = this.items.length - 1; + } else if (this.cursor > 0) { + this.cursor--; + } + return this.items[this.cursor] ?? null; + } + + down(): string | null { + if (this.cursor < 0) { + return null; + } + this.cursor++; + if (this.cursor >= this.items.length) { + this.cursor = -1; + return null; + } + return this.items[this.cursor] ?? null; + } + + reset(): void { + this.cursor = -1; + } +} diff --git a/ui/src/ui/chat/pinned-messages.ts b/ui/src/ui/chat/pinned-messages.ts new file mode 100644 index 00000000000..4914b0db32a --- /dev/null +++ b/ui/src/ui/chat/pinned-messages.ts @@ -0,0 +1,61 @@ +const PREFIX = "openclaw:pinned:"; + +export class PinnedMessages { + private key: string; + private _indices = new Set(); + + constructor(sessionKey: string) { + this.key = PREFIX + sessionKey; + this.load(); + } + + get indices(): Set { + return this._indices; + } + + has(index: number): boolean { + return this._indices.has(index); + } + + pin(index: number): void { + this._indices.add(index); + this.save(); + } + + unpin(index: number): void { + this._indices.delete(index); + this.save(); + } + + toggle(index: number): void { + if (this._indices.has(index)) { + this.unpin(index); + } else { + this.pin(index); + } + } + + clear(): void { + this._indices.clear(); + this.save(); + } + + private load(): void { + try { + const raw = localStorage.getItem(this.key); + if (!raw) { + return; + } + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + this._indices = new Set(arr.filter((n) => typeof n === "number")); + } + } catch { + // ignore + } + } + + private save(): void { + localStorage.setItem(this.key, JSON.stringify([...this._indices])); + } +} diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts new file mode 100644 index 00000000000..ca30fdc54d5 --- /dev/null +++ b/ui/src/ui/chat/slash-command-executor.node.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../gateway.ts"; +import type { GatewaySessionRow } from "../types.ts"; +import { executeSlashCommand } from "./slash-command-executor.ts"; + +function row(key: string, overrides?: Partial): GatewaySessionRow { + return { + key, + spawnedBy: overrides?.spawnedBy, + kind: "direct", + updatedAt: null, + ...overrides, + }; +} + +describe("executeSlashCommand /kill", () => { + it("aborts every sub-agent session for /kill all", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("main"), + row("agent:main:subagent:one", { spawnedBy: "main" }), + row("agent:main:subagent:parent", { spawnedBy: "main" }), + row("agent:main:subagent:parent:subagent:child", { + spawnedBy: "agent:main:subagent:parent", + }), + row("agent:other:main"), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "kill", + "all", + ); + + expect(result.content).toBe("Aborted 3 sub-agent sessions."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { + sessionKey: "agent:main:subagent:one", + }); + expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:parent", + }); + expect(request).toHaveBeenNthCalledWith(4, "chat.abort", { + sessionKey: "agent:main:subagent:parent:subagent:child", + }); + }); + + it("aborts matching sub-agent sessions for /kill ", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }), + row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }), + row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "kill", + "main", + ); + + expect(result.content).toBe("Aborted 2 matching sub-agent sessions for `main`."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { + sessionKey: "agent:main:subagent:one", + }); + expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:two", + }); + }); + + it("does not exact-match a session key outside the current subagent subtree", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:subagent:parent", { spawnedBy: "agent:main:main" }), + row("agent:main:subagent:parent:subagent:child", { + spawnedBy: "agent:main:subagent:parent", + }), + row("agent:main:subagent:sibling", { spawnedBy: "agent:main:main" }), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:subagent:parent", + "kill", + "agent:main:subagent:sibling", + ); + + expect(result.content).toBe( + "No matching sub-agent sessions found for `agent:main:subagent:sibling`.", + ); + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + }); + + it("returns a no-op summary when matching sessions have no active runs", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }), + row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: false }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "kill", + "all", + ); + + expect(result.content).toBe("No active sub-agent runs to abort."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { + sessionKey: "agent:main:subagent:one", + }); + expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:two", + }); + }); + + it("treats the legacy main session key as the default agent scope", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("main"), + row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }), + row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }), + row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "main", + "kill", + "all", + ); + + expect(result.content).toBe("Aborted 2 sub-agent sessions."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { + sessionKey: "agent:main:subagent:one", + }); + expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:two", + }); + }); + + it("does not abort unrelated same-agent subagents from another root session", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:main"), + row("agent:main:subagent:mine", { spawnedBy: "agent:main:main" }), + row("agent:main:subagent:mine:subagent:child", { + spawnedBy: "agent:main:subagent:mine", + }), + row("agent:main:subagent:other-root", { + spawnedBy: "agent:main:discord:dm:alice", + }), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "kill", + "all", + ); + + expect(result.content).toBe("Aborted 2 sub-agent sessions."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { + sessionKey: "agent:main:subagent:mine", + }); + expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:mine:subagent:child", + }); + }); +}); + +describe("executeSlashCommand directives", () => { + it("resolves the legacy main alias for bare /model", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + defaults: { model: "default-model" }, + sessions: [ + row("agent:main:main", { + model: "gpt-4.1-mini", + }), + ], + }; + } + if (method === "models.list") { + return { + models: [{ id: "gpt-4.1-mini" }, { id: "gpt-4.1" }], + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "main", + "model", + "", + ); + + expect(result.content).toBe( + "**Current model:** `gpt-4.1-mini`\n**Available:** `gpt-4.1-mini`, `gpt-4.1`", + ); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "models.list", {}); + }); + + it("resolves the legacy main alias for /usage", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:main", { + model: "gpt-4.1-mini", + inputTokens: 1200, + outputTokens: 300, + totalTokens: 1500, + contextTokens: 4000, + }), + ], + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "main", + "usage", + "", + ); + + expect(result.content).toBe( + "**Session Usage**\nInput: **1.2k** tokens\nOutput: **300** tokens\nTotal: **1.5k** tokens\nContext: **30%** of 4k\nModel: `gpt-4.1-mini`", + ); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + }); + + it("reports the current thinking level for bare /think", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:main", { + modelProvider: "openai", + model: "gpt-4.1-mini", + }), + ], + }; + } + if (method === "models.list") { + return { + models: [{ id: "gpt-4.1-mini", provider: "openai", reasoning: true }], + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "think", + "", + ); + + expect(result.content).toBe( + "Current thinking level: low.\nOptions: off, minimal, low, medium, high, adaptive.", + ); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "models.list", {}); + }); + + it("accepts minimal and xhigh thinking levels", async () => { + const request = vi.fn().mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ ok: true }); + + const minimal = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "think", + "minimal", + ); + const xhigh = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "think", + "xhigh", + ); + + expect(minimal.content).toBe("Thinking level set to **minimal**."); + expect(xhigh.content).toBe("Thinking level set to **xhigh**."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.patch", { + key: "agent:main:main", + thinkingLevel: "minimal", + }); + expect(request).toHaveBeenNthCalledWith(2, "sessions.patch", { + key: "agent:main:main", + thinkingLevel: "xhigh", + }); + }); + + it("reports the current verbose level for bare /verbose", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [row("agent:main:main", { verboseLevel: "full" })], + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "verbose", + "", + ); + + expect(result.content).toBe("Current verbose level: full.\nOptions: on, full, off."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + }); +}); diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts new file mode 100644 index 00000000000..999a21487b9 --- /dev/null +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -0,0 +1,545 @@ +/** + * Client-side execution engine for slash commands. + * Calls gateway RPC methods and returns formatted results. + */ + +import type { ModelCatalogEntry } from "../../../../src/agents/model-catalog.js"; +import { resolveThinkingDefault } from "../../../../src/agents/model-selection.js"; +import { + formatThinkingLevels, + normalizeThinkLevel, + normalizeVerboseLevel, +} from "../../../../src/auto-reply/thinking.js"; +import type { HealthSummary } from "../../../../src/commands/health.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { + DEFAULT_AGENT_ID, + DEFAULT_MAIN_KEY, + isSubagentSessionKey, + parseAgentSessionKey, +} from "../../../../src/routing/session-key.js"; +import type { GatewayBrowserClient } from "../gateway.ts"; +import type { AgentsListResult, GatewaySessionRow, SessionsListResult } from "../types.ts"; +import { SLASH_COMMANDS } from "./slash-commands.ts"; + +export type SlashCommandResult = { + /** Markdown-formatted result to display in chat. */ + content: string; + /** Side-effect action the caller should perform after displaying the result. */ + action?: + | "refresh" + | "export" + | "new-session" + | "reset" + | "stop" + | "clear" + | "toggle-focus" + | "navigate-usage"; +}; + +export async function executeSlashCommand( + client: GatewayBrowserClient, + sessionKey: string, + commandName: string, + args: string, +): Promise { + switch (commandName) { + case "help": + return executeHelp(); + case "status": + return await executeStatus(client); + case "new": + return { content: "Starting new session...", action: "new-session" }; + case "reset": + return { content: "Resetting session...", action: "reset" }; + case "stop": + return { content: "Stopping current run...", action: "stop" }; + case "clear": + return { content: "Chat history cleared.", action: "clear" }; + case "focus": + return { content: "Toggled focus mode.", action: "toggle-focus" }; + case "compact": + return await executeCompact(client, sessionKey); + case "model": + return await executeModel(client, sessionKey, args); + case "think": + return await executeThink(client, sessionKey, args); + case "verbose": + return await executeVerbose(client, sessionKey, args); + case "export": + return { content: "Exporting session...", action: "export" }; + case "usage": + return await executeUsage(client, sessionKey); + case "agents": + return await executeAgents(client); + case "kill": + return await executeKill(client, sessionKey, args); + default: + return { content: `Unknown command: \`/${commandName}\`` }; + } +} + +// โ”€โ”€ Command Implementations โ”€โ”€ + +function executeHelp(): SlashCommandResult { + const lines = ["**Available Commands**\n"]; + let currentCategory = ""; + + for (const cmd of SLASH_COMMANDS) { + const cat = cmd.category ?? "session"; + if (cat !== currentCategory) { + currentCategory = cat; + lines.push(`**${cat.charAt(0).toUpperCase() + cat.slice(1)}**`); + } + const argStr = cmd.args ? ` ${cmd.args}` : ""; + const local = cmd.executeLocal ? "" : " *(agent)*"; + lines.push(`\`/${cmd.name}${argStr}\` โ€” ${cmd.description}${local}`); + } + + lines.push("\nType `/` to open the command menu."); + return { content: lines.join("\n") }; +} + +async function executeStatus(client: GatewayBrowserClient): Promise { + try { + const health = await client.request("health", {}); + const status = health.ok ? "Healthy" : "Degraded"; + const agentCount = health.agents?.length ?? 0; + const sessionCount = health.sessions?.count ?? 0; + const lines = [ + `**System Status:** ${status}`, + `**Agents:** ${agentCount}`, + `**Sessions:** ${sessionCount}`, + `**Default Agent:** ${health.defaultAgentId || "none"}`, + ]; + if (health.durationMs) { + lines.push(`**Response:** ${health.durationMs}ms`); + } + return { content: lines.join("\n") }; + } catch (err) { + return { content: `Failed to fetch status: ${String(err)}` }; + } +} + +async function executeCompact( + client: GatewayBrowserClient, + sessionKey: string, +): Promise { + try { + await client.request("sessions.compact", { key: sessionKey }); + return { content: "Context compacted successfully.", action: "refresh" }; + } catch (err) { + return { content: `Compaction failed: ${String(err)}` }; + } +} + +async function executeModel( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + if (!args) { + try { + const [sessions, models] = await Promise.all([ + client.request("sessions.list", {}), + client.request<{ models: ModelCatalogEntry[] }>("models.list", {}), + ]); + const session = resolveCurrentSession(sessions, sessionKey); + const model = session?.model || sessions?.defaults?.model || "default"; + const available = models?.models?.map((m: ModelCatalogEntry) => m.id) ?? []; + const lines = [`**Current model:** \`${model}\``]; + if (available.length > 0) { + lines.push( + `**Available:** ${available + .slice(0, 10) + .map((m: string) => `\`${m}\``) + .join(", ")}${available.length > 10 ? ` +${available.length - 10} more` : ""}`, + ); + } + return { content: lines.join("\n") }; + } catch (err) { + return { content: `Failed to get model info: ${String(err)}` }; + } + } + + try { + await client.request("sessions.patch", { key: sessionKey, model: args.trim() }); + return { content: `Model set to \`${args.trim()}\`.`, action: "refresh" }; + } catch (err) { + return { content: `Failed to set model: ${String(err)}` }; + } +} + +async function executeThink( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + const rawLevel = args.trim(); + if (!rawLevel) { + try { + const { session, models } = await loadThinkingCommandState(client, sessionKey); + return { + content: formatDirectiveOptions( + `Current thinking level: ${resolveCurrentThinkingLevel(session, models)}.`, + formatThinkingLevels(session?.modelProvider, session?.model), + ), + }; + } catch (err) { + return { content: `Failed to get thinking level: ${String(err)}` }; + } + } + + const level = normalizeThinkLevel(rawLevel); + if (!level) { + try { + const session = await loadCurrentSession(client, sessionKey); + return { + content: `Unrecognized thinking level "${rawLevel}". Valid levels: ${formatThinkingLevels(session?.modelProvider, session?.model)}.`, + }; + } catch (err) { + return { content: `Failed to validate thinking level: ${String(err)}` }; + } + } + + try { + await client.request("sessions.patch", { key: sessionKey, thinkingLevel: level }); + return { + content: `Thinking level set to **${level}**.`, + action: "refresh", + }; + } catch (err) { + return { content: `Failed to set thinking level: ${String(err)}` }; + } +} + +async function executeVerbose( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + const rawLevel = args.trim(); + if (!rawLevel) { + try { + const session = await loadCurrentSession(client, sessionKey); + return { + content: formatDirectiveOptions( + `Current verbose level: ${normalizeVerboseLevel(session?.verboseLevel) ?? "off"}.`, + "on, full, off", + ), + }; + } catch (err) { + return { content: `Failed to get verbose level: ${String(err)}` }; + } + } + + const level = normalizeVerboseLevel(rawLevel); + if (!level) { + return { + content: `Unrecognized verbose level "${rawLevel}". Valid levels: off, on, full.`, + }; + } + + try { + await client.request("sessions.patch", { key: sessionKey, verboseLevel: level }); + return { + content: `Verbose mode set to **${level}**.`, + action: "refresh", + }; + } catch (err) { + return { content: `Failed to set verbose mode: ${String(err)}` }; + } +} + +async function executeUsage( + client: GatewayBrowserClient, + sessionKey: string, +): Promise { + try { + const sessions = await client.request("sessions.list", {}); + const session = resolveCurrentSession(sessions, sessionKey); + if (!session) { + return { content: "No active session." }; + } + const input = session.inputTokens ?? 0; + const output = session.outputTokens ?? 0; + const total = session.totalTokens ?? input + output; + const ctx = session.contextTokens ?? 0; + const pct = ctx > 0 ? Math.round((input / ctx) * 100) : null; + + const lines = [ + "**Session Usage**", + `Input: **${fmtTokens(input)}** tokens`, + `Output: **${fmtTokens(output)}** tokens`, + `Total: **${fmtTokens(total)}** tokens`, + ]; + if (pct !== null) { + lines.push(`Context: **${pct}%** of ${fmtTokens(ctx)}`); + } + if (session.model) { + lines.push(`Model: \`${session.model}\``); + } + return { content: lines.join("\n") }; + } catch (err) { + return { content: `Failed to get usage: ${String(err)}` }; + } +} + +async function executeAgents(client: GatewayBrowserClient): Promise { + try { + const result = await client.request("agents.list", {}); + const agents = result?.agents ?? []; + if (agents.length === 0) { + return { content: "No agents configured." }; + } + const lines = [`**Agents** (${agents.length})\n`]; + for (const agent of agents) { + const isDefault = agent.id === result?.defaultId; + const name = agent.identity?.name || agent.name || agent.id; + const marker = isDefault ? " *(default)*" : ""; + lines.push(`- \`${agent.id}\` โ€” ${name}${marker}`); + } + return { content: lines.join("\n") }; + } catch (err) { + return { content: `Failed to list agents: ${String(err)}` }; + } +} + +async function executeKill( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + const target = args.trim(); + if (!target) { + return { content: "Usage: `/kill `" }; + } + try { + const sessions = await client.request("sessions.list", {}); + const matched = resolveKillTargets(sessions?.sessions ?? [], sessionKey, target); + if (matched.length === 0) { + return { + content: + target.toLowerCase() === "all" + ? "No active sub-agent sessions found." + : `No matching sub-agent sessions found for \`${target}\`.`, + }; + } + + const results = await Promise.allSettled( + matched.map((key) => + client.request<{ aborted?: boolean }>("chat.abort", { sessionKey: key }), + ), + ); + const rejected = results.filter((entry) => entry.status === "rejected"); + const successCount = results.filter( + (entry) => + entry.status === "fulfilled" && (entry.value as { aborted?: boolean })?.aborted !== false, + ).length; + if (successCount === 0) { + if (rejected.length === 0) { + return { + content: + target.toLowerCase() === "all" + ? "No active sub-agent runs to abort." + : `No active runs matched \`${target}\`.`, + }; + } + throw rejected[0]?.reason ?? new Error("abort failed"); + } + + if (target.toLowerCase() === "all") { + return { + content: + successCount === matched.length + ? `Aborted ${successCount} sub-agent session${successCount === 1 ? "" : "s"}.` + : `Aborted ${successCount} of ${matched.length} sub-agent sessions.`, + }; + } + + return { + content: + successCount === matched.length + ? `Aborted ${successCount} matching sub-agent session${successCount === 1 ? "" : "s"} for \`${target}\`.` + : `Aborted ${successCount} of ${matched.length} matching sub-agent sessions for \`${target}\`.`, + }; + } catch (err) { + return { content: `Failed to abort: ${String(err)}` }; + } +} + +function resolveKillTargets( + sessions: GatewaySessionRow[], + currentSessionKey: string, + target: string, +): string[] { + const normalizedTarget = target.trim().toLowerCase(); + if (!normalizedTarget) { + return []; + } + + const keys = new Set(); + const normalizedCurrentSessionKey = currentSessionKey.trim().toLowerCase(); + const currentParsed = parseAgentSessionKey(normalizedCurrentSessionKey); + const currentAgentId = + currentParsed?.agentId ?? + (normalizedCurrentSessionKey === DEFAULT_MAIN_KEY ? DEFAULT_AGENT_ID : undefined); + const sessionIndex = buildSessionIndex(sessions); + for (const session of sessions) { + const key = session?.key?.trim(); + if (!key || !isSubagentSessionKey(key)) { + continue; + } + const normalizedKey = key.toLowerCase(); + const parsed = parseAgentSessionKey(normalizedKey); + const belongsToCurrentSession = isWithinCurrentSessionSubtree( + normalizedKey, + normalizedCurrentSessionKey, + sessionIndex, + currentAgentId, + parsed?.agentId, + ); + const isMatch = + (normalizedTarget === "all" && belongsToCurrentSession) || + (belongsToCurrentSession && normalizedKey === normalizedTarget) || + (belongsToCurrentSession && + ((parsed?.agentId ?? "") === normalizedTarget || + normalizedKey.endsWith(`:subagent:${normalizedTarget}`) || + normalizedKey === `subagent:${normalizedTarget}`)); + if (isMatch) { + keys.add(key); + } + } + return [...keys]; +} + +function isWithinCurrentSessionSubtree( + candidateSessionKey: string, + currentSessionKey: string, + sessionIndex: Map, + currentAgentId: string | undefined, + candidateAgentId: string | undefined, +): boolean { + if (!currentAgentId || candidateAgentId !== currentAgentId) { + return false; + } + + const currentAliases = resolveEquivalentSessionKeys(currentSessionKey, currentAgentId); + const seen = new Set(); + let parentSessionKey = normalizeSessionKey(sessionIndex.get(candidateSessionKey)?.spawnedBy); + while (parentSessionKey && !seen.has(parentSessionKey)) { + if (currentAliases.has(parentSessionKey)) { + return true; + } + seen.add(parentSessionKey); + parentSessionKey = normalizeSessionKey(sessionIndex.get(parentSessionKey)?.spawnedBy); + } + + // Older gateways may not include spawnedBy on session rows yet; keep prefix + // matching for nested subagent sessions as a compatibility fallback. + return isSubagentSessionKey(currentSessionKey) + ? candidateSessionKey.startsWith(`${currentSessionKey}:subagent:`) + : false; +} + +function buildSessionIndex(sessions: GatewaySessionRow[]): Map { + const index = new Map(); + for (const session of sessions) { + const normalizedKey = normalizeSessionKey(session?.key); + if (!normalizedKey) { + continue; + } + index.set(normalizedKey, session); + } + return index; +} + +function normalizeSessionKey(key?: string | null): string | undefined { + const normalized = key?.trim().toLowerCase(); + return normalized || undefined; +} + +function resolveEquivalentSessionKeys( + currentSessionKey: string, + currentAgentId: string | undefined, +): Set { + const keys = new Set([currentSessionKey]); + if (currentAgentId === DEFAULT_AGENT_ID) { + const canonicalDefaultMain = `agent:${DEFAULT_AGENT_ID}:main`; + if (currentSessionKey === DEFAULT_MAIN_KEY) { + keys.add(canonicalDefaultMain); + } else if (currentSessionKey === canonicalDefaultMain) { + keys.add(DEFAULT_MAIN_KEY); + } + } + return keys; +} + +function formatDirectiveOptions(text: string, options: string): string { + return `${text}\nOptions: ${options}.`; +} + +async function loadCurrentSession( + client: GatewayBrowserClient, + sessionKey: string, +): Promise { + const sessions = await client.request("sessions.list", {}); + return resolveCurrentSession(sessions, sessionKey); +} + +function resolveCurrentSession( + sessions: SessionsListResult | undefined, + sessionKey: string, +): GatewaySessionRow | undefined { + const normalizedSessionKey = normalizeSessionKey(sessionKey); + const currentAgentId = + parseAgentSessionKey(normalizedSessionKey ?? "")?.agentId ?? + (normalizedSessionKey === DEFAULT_MAIN_KEY ? DEFAULT_AGENT_ID : undefined); + const aliases = normalizedSessionKey + ? resolveEquivalentSessionKeys(normalizedSessionKey, currentAgentId) + : new Set(); + return sessions?.sessions?.find((session: GatewaySessionRow) => { + const key = normalizeSessionKey(session.key); + return key ? aliases.has(key) : false; + }); +} + +async function loadThinkingCommandState(client: GatewayBrowserClient, sessionKey: string) { + const [sessions, models] = await Promise.all([ + client.request("sessions.list", {}), + client.request<{ models: ModelCatalogEntry[] }>("models.list", {}), + ]); + return { + session: resolveCurrentSession(sessions, sessionKey), + models: models?.models ?? [], + }; +} + +function resolveCurrentThinkingLevel( + session: GatewaySessionRow | undefined, + models: ModelCatalogEntry[], +): string { + const persisted = normalizeThinkLevel(session?.thinkingLevel); + if (persisted) { + return persisted; + } + if (!session?.modelProvider || !session.model) { + return "off"; + } + return resolveThinkingDefault({ + cfg: {} as OpenClawConfig, + provider: session.modelProvider, + model: session.model, + catalog: models, + }); +} + +function fmtTokens(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + } + if (n >= 1_000) { + return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + } + return String(n); +} diff --git a/ui/src/ui/chat/slash-commands.node.test.ts b/ui/src/ui/chat/slash-commands.node.test.ts new file mode 100644 index 00000000000..cb07109df9f --- /dev/null +++ b/ui/src/ui/chat/slash-commands.node.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { parseSlashCommand } from "./slash-commands.ts"; + +describe("parseSlashCommand", () => { + it("parses commands with an optional colon separator", () => { + expect(parseSlashCommand("/think: high")).toMatchObject({ + command: { name: "think" }, + args: "high", + }); + expect(parseSlashCommand("/think:high")).toMatchObject({ + command: { name: "think" }, + args: "high", + }); + expect(parseSlashCommand("/help:")).toMatchObject({ + command: { name: "help" }, + args: "", + }); + }); + + it("still parses space-delimited commands", () => { + expect(parseSlashCommand("/verbose full")).toMatchObject({ + command: { name: "verbose" }, + args: "full", + }); + }); +}); diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts new file mode 100644 index 00000000000..27acd90025e --- /dev/null +++ b/ui/src/ui/chat/slash-commands.ts @@ -0,0 +1,222 @@ +import type { IconName } from "../icons.ts"; + +export type SlashCommandCategory = "session" | "model" | "agents" | "tools"; + +export type SlashCommandDef = { + name: string; + description: string; + args?: string; + icon?: IconName; + category?: SlashCommandCategory; + /** When true, the command is executed client-side via RPC instead of sent to the agent. */ + executeLocal?: boolean; + /** Fixed argument choices for inline hints. */ + argOptions?: string[]; + /** Keyboard shortcut hint shown in the menu (display only). */ + shortcut?: string; +}; + +export const SLASH_COMMANDS: SlashCommandDef[] = [ + // โ”€โ”€ Session โ”€โ”€ + { + name: "new", + description: "Start a new session", + icon: "circle", + category: "session", + executeLocal: true, + }, + { + name: "reset", + description: "Reset current session", + icon: "loader", + category: "session", + executeLocal: true, + }, + { + name: "compact", + description: "Compact session context", + icon: "loader", + category: "session", + executeLocal: true, + }, + { + name: "stop", + description: "Stop current run", + icon: "x", + category: "session", + executeLocal: true, + }, + { + name: "clear", + description: "Clear chat history", + icon: "x", + category: "session", + executeLocal: true, + }, + { + name: "focus", + description: "Toggle focus mode", + icon: "search", + category: "session", + executeLocal: true, + }, + + // โ”€โ”€ Model โ”€โ”€ + { + name: "model", + description: "Show or set model", + args: "", + icon: "brain", + category: "model", + executeLocal: true, + }, + { + name: "think", + description: "Set thinking level", + args: "", + icon: "brain", + category: "model", + executeLocal: true, + argOptions: ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"], + }, + { + name: "verbose", + description: "Toggle verbose mode", + args: "", + icon: "fileCode", + category: "model", + executeLocal: true, + argOptions: ["on", "off", "full"], + }, + + // โ”€โ”€ Tools โ”€โ”€ + { + name: "help", + description: "Show available commands", + icon: "book", + category: "tools", + executeLocal: true, + }, + { + name: "status", + description: "Show system status", + icon: "barChart", + category: "tools", + executeLocal: true, + }, + { + name: "export", + description: "Export session to Markdown", + icon: "arrowDown", + category: "tools", + executeLocal: true, + }, + { + name: "usage", + description: "Show token usage", + icon: "barChart", + category: "tools", + executeLocal: true, + }, + + // โ”€โ”€ Agents โ”€โ”€ + { + name: "agents", + description: "List agents", + icon: "monitor", + category: "agents", + executeLocal: true, + }, + { + name: "kill", + description: "Abort sub-agents", + args: "", + icon: "x", + category: "agents", + executeLocal: true, + }, + { + name: "skill", + description: "Run a skill", + args: "", + icon: "zap", + category: "tools", + }, + { + name: "steer", + description: "Steer a sub-agent", + args: " ", + icon: "zap", + category: "agents", + }, +]; + +const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "tools", "agents"]; + +export const CATEGORY_LABELS: Record = { + session: "Session", + model: "Model", + agents: "Agents", + tools: "Tools", +}; + +export function getSlashCommandCompletions(filter: string): SlashCommandDef[] { + const lower = filter.toLowerCase(); + const commands = lower + ? SLASH_COMMANDS.filter( + (cmd) => cmd.name.startsWith(lower) || cmd.description.toLowerCase().includes(lower), + ) + : SLASH_COMMANDS; + return commands.toSorted((a, b) => { + const ai = CATEGORY_ORDER.indexOf(a.category ?? "session"); + const bi = CATEGORY_ORDER.indexOf(b.category ?? "session"); + if (ai !== bi) { + return ai - bi; + } + // Exact prefix matches first + if (lower) { + const aExact = a.name.startsWith(lower) ? 0 : 1; + const bExact = b.name.startsWith(lower) ? 0 : 1; + if (aExact !== bExact) { + return aExact - bExact; + } + } + return 0; + }); +} + +export type ParsedSlashCommand = { + command: SlashCommandDef; + args: string; +}; + +/** + * Parse a message as a slash command. Returns null if it doesn't match. + * Supports `/command`, `/command args...`, and `/command: args...`. + */ +export function parseSlashCommand(text: string): ParsedSlashCommand | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("/")) { + return null; + } + + const body = trimmed.slice(1); + const firstSeparator = body.search(/[\s:]/u); + const name = firstSeparator === -1 ? body : body.slice(0, firstSeparator); + let remainder = firstSeparator === -1 ? "" : body.slice(firstSeparator).trimStart(); + if (remainder.startsWith(":")) { + remainder = remainder.slice(1).trimStart(); + } + const args = remainder.trim(); + + if (!name) { + return null; + } + + const command = SLASH_COMMANDS.find((cmd) => cmd.name === name.toLowerCase()); + if (!command) { + return null; + } + + return { command, args }; +} diff --git a/ui/src/ui/chat/speech.ts b/ui/src/ui/chat/speech.ts new file mode 100644 index 00000000000..4db4e6944a1 --- /dev/null +++ b/ui/src/ui/chat/speech.ts @@ -0,0 +1,225 @@ +/** + * Browser-native speech services: STT via SpeechRecognition, TTS via SpeechSynthesis. + * Falls back gracefully when APIs are unavailable. + */ + +// โ”€โ”€โ”€ STT (Speech-to-Text) โ”€โ”€โ”€ + +type SpeechRecognitionEvent = Event & { + results: SpeechRecognitionResultList; + resultIndex: number; +}; + +type SpeechRecognitionErrorEvent = Event & { + error: string; + message?: string; +}; + +interface SpeechRecognitionInstance extends EventTarget { + continuous: boolean; + interimResults: boolean; + lang: string; + start(): void; + stop(): void; + abort(): void; + onresult: ((event: SpeechRecognitionEvent) => void) | null; + onerror: ((event: SpeechRecognitionErrorEvent) => void) | null; + onend: (() => void) | null; + onstart: (() => void) | null; +} + +type SpeechRecognitionCtor = new () => SpeechRecognitionInstance; + +function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null { + const w = globalThis as Record; + return (w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null) as SpeechRecognitionCtor | null; +} + +export function isSttSupported(): boolean { + return getSpeechRecognitionCtor() !== null; +} + +export type SttCallbacks = { + onTranscript: (text: string, isFinal: boolean) => void; + onStart?: () => void; + onEnd?: () => void; + onError?: (error: string) => void; +}; + +let activeRecognition: SpeechRecognitionInstance | null = null; + +export function startStt(callbacks: SttCallbacks): boolean { + const Ctor = getSpeechRecognitionCtor(); + if (!Ctor) { + callbacks.onError?.("Speech recognition is not supported in this browser"); + return false; + } + + stopStt(); + + const recognition = new Ctor(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = navigator.language || "en-US"; + + recognition.addEventListener("start", () => callbacks.onStart?.()); + + recognition.addEventListener("result", (event) => { + const speechEvent = event as unknown as SpeechRecognitionEvent; + let interimTranscript = ""; + let finalTranscript = ""; + + for (let i = speechEvent.resultIndex; i < speechEvent.results.length; i++) { + const result = speechEvent.results[i]; + if (!result?.[0]) { + continue; + } + const transcript = result[0].transcript; + if (result.isFinal) { + finalTranscript += transcript; + } else { + interimTranscript += transcript; + } + } + + if (finalTranscript) { + callbacks.onTranscript(finalTranscript, true); + } else if (interimTranscript) { + callbacks.onTranscript(interimTranscript, false); + } + }); + + recognition.addEventListener("error", (event) => { + const speechEvent = event as unknown as SpeechRecognitionErrorEvent; + if (speechEvent.error === "aborted" || speechEvent.error === "no-speech") { + return; + } + callbacks.onError?.(speechEvent.error); + }); + + recognition.addEventListener("end", () => { + if (activeRecognition === recognition) { + activeRecognition = null; + } + callbacks.onEnd?.(); + }); + + activeRecognition = recognition; + recognition.start(); + return true; +} + +export function stopStt(): void { + if (activeRecognition) { + const r = activeRecognition; + activeRecognition = null; + try { + r.stop(); + } catch { + // already stopped + } + } +} + +export function isSttActive(): boolean { + return activeRecognition !== null; +} + +// โ”€โ”€โ”€ TTS (Text-to-Speech) โ”€โ”€โ”€ + +export function isTtsSupported(): boolean { + return "speechSynthesis" in globalThis; +} + +let currentUtterance: SpeechSynthesisUtterance | null = null; + +export function speakText( + text: string, + opts?: { + onStart?: () => void; + onEnd?: () => void; + onError?: (error: string) => void; + }, +): boolean { + if (!isTtsSupported()) { + opts?.onError?.("Speech synthesis is not supported in this browser"); + return false; + } + + stopTts(); + + const cleaned = stripMarkdown(text); + if (!cleaned.trim()) { + return false; + } + + const utterance = new SpeechSynthesisUtterance(cleaned); + utterance.rate = 1.0; + utterance.pitch = 1.0; + + utterance.addEventListener("start", () => opts?.onStart?.()); + utterance.addEventListener("end", () => { + if (currentUtterance === utterance) { + currentUtterance = null; + } + opts?.onEnd?.(); + }); + utterance.addEventListener("error", (e) => { + if (currentUtterance === utterance) { + currentUtterance = null; + } + if (e.error === "canceled" || e.error === "interrupted") { + return; + } + opts?.onError?.(e.error); + }); + + currentUtterance = utterance; + speechSynthesis.speak(utterance); + return true; +} + +export function stopTts(): void { + if (currentUtterance) { + currentUtterance = null; + } + if (isTtsSupported()) { + speechSynthesis.cancel(); + } +} + +export function isTtsSpeaking(): boolean { + return isTtsSupported() && speechSynthesis.speaking; +} + +/** Strip common markdown syntax for cleaner speech output. */ +function stripMarkdown(text: string): string { + return ( + text + // code blocks + .replace(/```[\s\S]*?```/g, "") + // inline code + .replace(/`[^`]+`/g, "") + // images + .replace(/!\[.*?\]\(.*?\)/g, "") + // links โ†’ keep text + .replace(/\[([^\]]+)\]\(.*?\)/g, "$1") + // headings + .replace(/^#{1,6}\s+/gm, "") + // bold/italic + .replace(/\*{1,3}(.*?)\*{1,3}/g, "$1") + .replace(/_{1,3}(.*?)_{1,3}/g, "$1") + // blockquotes + .replace(/^>\s?/gm, "") + // horizontal rules + .replace(/^[-*_]{3,}\s*$/gm, "") + // list markers + .replace(/^\s*[-*+]\s+/gm, "") + .replace(/^\s*\d+\.\s+/gm, "") + // HTML tags + .replace(/<[^>]+>/g, "") + // collapse whitespace + .replace(/\n{3,}/g, "\n\n") + .trim() + ); +} diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index f87b498100a..7cde5adee61 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -395,6 +395,7 @@ export type AgentsFilesSetResult = { export type GatewaySessionRow = { key: string; + spawnedBy?: string; kind: "direct" | "group" | "global" | "unknown"; label?: string; displayName?: string; From 9aeaa19e9e15b3376fd7521b7987d64b6ea3914f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 03:53:06 -0400 Subject: [PATCH 38/48] Agents: clear invalidated Kimi tool arg repair (#43824) --- .../pi-embedded-runner/run/attempt.test.ts | 45 +++++++++++++++++++ src/agents/pi-embedded-runner/run/attempt.ts | 23 ++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 33a4f9654df..0203721224f 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -560,6 +560,51 @@ describe("wrapStreamFnRepairMalformedToolCallArguments", () => { 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", () => { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 790323b8232..2f77b46aff5 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -557,6 +557,25 @@ function repairToolCallArgumentsInMessage( 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>, @@ -637,6 +656,10 @@ function wrapStreamRepairMalformedToolCallArguments( `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); } } } From 97683071b507856264c80c2a14cfdfb3e8c74c4b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 04:01:49 -0400 Subject: [PATCH 39/48] Tests: extend exec allowlist glob coverage --- src/infra/exec-allowlist-pattern.test.ts | 10 ++++++++++ src/infra/exec-allowlist-pattern.ts | 5 +++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/infra/exec-allowlist-pattern.test.ts b/src/infra/exec-allowlist-pattern.test.ts index 2c45e12627f..1ac34112311 100644 --- a/src/infra/exec-allowlist-pattern.test.ts +++ b/src/infra/exec-allowlist-pattern.test.ts @@ -7,8 +7,18 @@ describe("matchesExecAllowlistPattern", () => { 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 cdf84dfc51e..96e93b6f797 100644 --- a/src/infra/exec-allowlist-pattern.ts +++ b/src/infra/exec-allowlist-pattern.ts @@ -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; } @@ -59,7 +60,7 @@ function compileGlobRegex(pattern: string): RegExp { if (globRegexCache.size >= GLOB_REGEX_CACHE_LIMIT) { globRegexCache.clear(); } - globRegexCache.set(pattern, compiled); + globRegexCache.set(cacheKey, compiled); return compiled; } From 46a332385d1130ec128a1351418a9ab698dfabf4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 04:20:00 -0400 Subject: [PATCH 40/48] Gateway: keep spawned workspace overrides internal (#43801) * Gateway: keep spawned workspace overrides internal * Changelog: note GHSA-2rqg agent boundary fix * Gateway: persist spawned workspace inheritance in sessions * Agents: clean failed lineage spawn state * Tests: cover lineage attachment cleanup * Tests: cover lineage thread cleanup --- CHANGELOG.md | 1 + ...agents.sessions-spawn-depth-limits.test.ts | 14 ++++- src/agents/sessions-spawn-hooks.test.ts | 32 +++++++++++ src/agents/subagent-spawn.attachments.test.ts | 55 ++++++++++++++++++- src/agents/subagent-spawn.ts | 43 ++++++++++++++- src/config/sessions/types.ts | 2 + src/gateway/protocol/schema/agent.ts | 2 - src/gateway/protocol/schema/sessions.ts | 1 + src/gateway/server-methods/agent.test.ts | 55 +++++++++++++------ src/gateway/server-methods/agent.ts | 15 ++--- src/gateway/sessions-patch.test.ts | 20 +++++++ src/gateway/sessions-patch.ts | 21 +++++++ 12 files changed, 226 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf7a60071bf..0632ab5e271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - 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/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 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/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/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/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 75d560ba92b..eaa54860a10 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -110,8 +110,6 @@ export const AgentParamsSchema = Type.Object( ), 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/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/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) { From f37815b32334e6fbc1de505f5584de4f2e6967da Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 04:21:03 -0400 Subject: [PATCH 41/48] Gateway: block profile mutations via browser.request (#43800) * Gateway: block profile mutations via browser.request * Changelog: note GHSA-vmhq browser request fix * Gateway: normalize browser.request profile guard paths --- CHANGELOG.md | 1 + .../browser.profile-from-body.test.ts | 38 +++++++++++++++++++ src/gateway/server-methods/browser.ts | 31 +++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0632ab5e271..92c8fe7021b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - 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. 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; From 658bd54ecf83cea90e905d4f86189af08eaf43f7 Mon Sep 17 00:00:00 2001 From: Xaden Ryan <165437834+xadenryan@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:21:35 -0600 Subject: [PATCH 42/48] feat(llm-task): add thinking override Co-authored-by: Xaden Ryan <165437834+xadenryan@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/tools/llm-task.md | 2 + docs/tools/lobster.md | 1 + extensions/llm-task/README.md | 1 + extensions/llm-task/src/llm-task-tool.test.ts | 53 +++++++++++++++++++ extensions/llm-task/src/llm-task-tool.ts | 22 +++++++- src/plugin-sdk/llm-task.ts | 6 +++ 7 files changed, 85 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92c8fe7021b..8551a0ccd8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,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 diff --git a/docs/tools/llm-task.md b/docs/tools/llm-task.md index e6f574d078e..16de8230f84 100644 --- a/docs/tools/llm-task.md +++ b/docs/tools/llm-task.md @@ -75,6 +75,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) @@ -90,6 +91,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/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/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"; From 46cb73da37bf098e03d7effd049f679b697e6d75 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Thu, 12 Mar 2026 03:26:39 -0500 Subject: [PATCH 43/48] feat(ui): utilities, theming, and i18n updates (slice 2/3 of dashboard-v2) (#41500) * feat(ui): add utilities, theming, and i18n updates (slice 2 of dashboard-v2) UI utilities and theming improvements extracted from dashboard-v2-structure: Icons & formatting: - icons.ts: expanded icon set for new dashboard views - format.ts: date/number formatting helpers - tool-labels.ts: human-readable tool name mappings Theming: - theme.ts: enhanced theme resolution and system theme support - theme-transition.ts: simplified transition logic - storage.ts: theme parsing improvements for settings persistence Navigation & types: - navigation.ts: extended tab definitions for dashboard-v2 - app-view-state.ts: expanded view state management - types.ts: new type definitions (HealthSummary, ModelCatalogEntry, etc.) Components: - components/dashboard-header.ts: reusable header component i18n: - Updated en, pt-BR, zh-CN, zh-TW locales with new dashboard strings All changes are additive or backwards-compatible. Build passes. Part of #36853. * ui: fix theme and locale review regressions * ui: fix review follow-ups for dashboard tabs * ui: allowlist locale password placeholder false positives * ui: fix theme mode and locale regressions * Vincentkoc code/pr 41500 route fix (#43829) * UI: keep unfinished settings routes hidden * UI: normalize light theme data token * UI: restore cron type compatibility --------- Co-authored-by: Vincent Koc --- .secrets.baseline | 4 +- ui/src/i18n/locales/en.ts | 80 +++++++-- ui/src/i18n/locales/pt-BR.ts | 80 +++++++-- ui/src/i18n/locales/zh-CN.ts | 80 +++++++-- ui/src/i18n/locales/zh-TW.ts | 80 +++++++-- ui/src/i18n/test/translate.test.ts | 78 +++++++-- ui/src/ui/app-render.helpers.ts | 16 +- ui/src/ui/app-settings.test.ts | 187 ++++++++++++++++++-- ui/src/ui/app-settings.ts | 56 ++++-- ui/src/ui/app-view-state.ts | 61 ++++++- ui/src/ui/app.ts | 16 +- ui/src/ui/components/dashboard-header.ts | 34 ++++ ui/src/ui/format.ts | 38 ++++ ui/src/ui/icons.ts | 213 +++++++++++++++++++++++ ui/src/ui/navigation-groups.test.ts | 56 ++++++ ui/src/ui/navigation.ts | 39 ++++- ui/src/ui/storage.node.test.ts | 62 ++++++- ui/src/ui/storage.ts | 26 ++- ui/src/ui/theme-transition.ts | 81 +-------- ui/src/ui/theme.test.ts | 38 ++++ ui/src/ui/theme.ts | 101 ++++++++++- ui/src/ui/tool-labels.ts | 39 +++++ ui/src/ui/types.ts | 82 +++++---- 23 files changed, 1306 insertions(+), 241 deletions(-) create mode 100644 ui/src/ui/components/dashboard-header.ts create mode 100644 ui/src/ui/navigation-groups.test.ts create mode 100644 ui/src/ui/theme.test.ts create mode 100644 ui/src/ui/tool-labels.ts 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/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) {