From 1168f5989031bf940acaa4efe0974d17f8efb687 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 30 Jan 2026 16:09:41 +0530 Subject: [PATCH 1/7] perf: skip redundant ui install in Dockerfile --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 904d1d97d..ad08bd37c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,6 @@ COPY . . RUN OPENCLAW_A2UI_SKIP_MISSING=1 pnpm build # Force pnpm for UI build (Bun may fail on ARM/Synology architectures) ENV OPENCLAW_PREFER_PNPM=1 -RUN pnpm ui:install RUN pnpm ui:build ENV NODE_ENV=production From fa9ec6e85452a81d21fcb046c770e519a16e8dda Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 30 Jan 2026 16:24:54 +0530 Subject: [PATCH 2/7] fix: add docker ui install changelog entry (#4584) (thanks @obviyus) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfaccc1f5..8b8b0e115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Status: stable. - Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD. - Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99. - Docs: update exe.dev install instructions. (#https://github.com/openclaw/openclaw/pull/3047) Thanks @zackerthescar. +- Build: skip redundant UI install step in the Dockerfile. (#4584) Thanks @obviyus. ### Breaking - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). From b05d57964b97216cd03ef7ed89351d4120bb9aa9 Mon Sep 17 00:00:00 2001 From: ThanhNguyxn Date: Fri, 30 Jan 2026 17:33:49 +0700 Subject: [PATCH 3/7] fix(telegram): properly nest overlapping HTML tags (#4071) Unify style and link closing in render.ts to use LIFO order across both element types, fixing cases where bold/italic spans containing autolinks produced invalid HTML like . --- src/markdown/render.ts | 22 +++++++++------------- src/telegram/format.test.ts | 10 ++++++++++ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/markdown/render.ts b/src/markdown/render.ts index 502ab69ef..dad833a05 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -87,40 +87,36 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions } const points = [...boundaries].sort((a, b) => a - b); - const stack: MarkdownStyleSpan[] = []; + // Unified stack for both styles and links, tracking close string and end position + const stack: { close: string; end: number }[] = []; let out = ""; for (let i = 0; i < points.length; i += 1) { const pos = points[i]; + // Close ALL elements (styles and links) in LIFO order at this position while (stack.length && stack[stack.length - 1]?.end === pos) { - const span = stack.pop(); - if (!span) break; - const marker = styleMarkers[span.style]; - if (marker) out += marker.close; - } - - const closingLinks = linkEnds.get(pos); - if (closingLinks && closingLinks.length > 0) { - for (const link of closingLinks) { - out += link.close; - } + const item = stack.pop(); + if (item) out += item.close; } + // Open links first (so they close after styles that start at the same position) const openingLinks = linkStarts.get(pos); if (openingLinks && openingLinks.length > 0) { for (const link of openingLinks) { out += link.open; + stack.push({ close: link.close, end: link.end }); } } + // Open styles second (so they close before links that start at the same position) const openingStyles = startsAt.get(pos); if (openingStyles) { for (const span of openingStyles) { const marker = styleMarkers[span.style]; if (!marker) continue; - stack.push(span); out += marker.open; + stack.push({ close: marker.close, end: span.end }); } } diff --git a/src/telegram/format.test.ts b/src/telegram/format.test.ts index 831782815..9b2cb60c6 100644 --- a/src/telegram/format.test.ts +++ b/src/telegram/format.test.ts @@ -47,4 +47,14 @@ describe("markdownToTelegramHtml", () => { const res = markdownToTelegramHtml("```js\nconst x = 1;\n```"); expect(res).toBe("
const x = 1;\n
"); }); + + it("properly nests overlapping bold and autolink (#4071)", () => { + const res = markdownToTelegramHtml("**start https://example.com** end"); + expect(res).toMatch(/start https:\/\/example\.com<\/a><\/b> end/); + }); + + it("properly nests link inside bold", () => { + const res = markdownToTelegramHtml("**bold [link](https://example.com) text**"); + expect(res).toBe('bold link text'); + }); }); From 8e5a68444584b272257f94b06151f1ce5224bd22 Mon Sep 17 00:00:00 2001 From: ThanhNguyxn Date: Fri, 30 Jan 2026 17:36:16 +0700 Subject: [PATCH 4/7] style: format test file --- src/telegram/format.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/telegram/format.test.ts b/src/telegram/format.test.ts index 9b2cb60c6..fbc811b72 100644 --- a/src/telegram/format.test.ts +++ b/src/telegram/format.test.ts @@ -50,7 +50,9 @@ describe("markdownToTelegramHtml", () => { it("properly nests overlapping bold and autolink (#4071)", () => { const res = markdownToTelegramHtml("**start https://example.com** end"); - expect(res).toMatch(/start https:\/\/example\.com<\/a><\/b> end/); + expect(res).toMatch( + /start https:\/\/example\.com<\/a><\/b> end/, + ); }); it("properly nests link inside bold", () => { From da71eaebd2f613f8afaddccdff12cb28c1d7e0f4 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 30 Jan 2026 16:45:45 +0530 Subject: [PATCH 5/7] fix: correct telegram html nesting (#4578) (thanks @ThanhNguyxn) --- CHANGELOG.md | 1 + src/markdown/render.ts | 58 +++++++++++++++++++++++++++++-------- src/telegram/format.test.ts | 10 +++++++ 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8b0e115..191c2172d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ Status: stable. ### Fixes - Telegram: use undici fetch for per-account proxy dispatcher. (#4456) Thanks @spiceoogway. +- Telegram: fix HTML nesting for overlapping styles and links. (#4578) Thanks @ThanhNguyxn. - Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796) - Telegram: accept numeric messageId/chatId in react action and honor channelId fallback. (#4533) Thanks @Ayush10. - Telegram: scope native skill commands to bound agent per bot. (#4360) Thanks @robhparker. diff --git a/src/markdown/render.ts b/src/markdown/render.ts index dad833a05..9793ab167 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -69,7 +69,6 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions } const linkStarts = new Map(); - const linkEnds = new Map(); if (options.buildLink) { for (const link of ir.links) { if (link.start === link.end) continue; @@ -80,15 +79,22 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions const openBucket = linkStarts.get(rendered.start); if (openBucket) openBucket.push(rendered); else linkStarts.set(rendered.start, [rendered]); - const closeBucket = linkEnds.get(rendered.end); - if (closeBucket) closeBucket.push(rendered); - else linkEnds.set(rendered.end, [rendered]); } } const points = [...boundaries].sort((a, b) => a - b); // Unified stack for both styles and links, tracking close string and end position const stack: { close: string; end: number }[] = []; + type OpeningItem = + | { end: number; open: string; close: string; kind: "link"; index: number } + | { + end: number; + open: string; + close: string; + kind: "style"; + style: MarkdownStyle; + index: number; + }; let out = ""; for (let i = 0; i < points.length; i += 1) { @@ -100,23 +106,51 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions if (item) out += item.close; } - // Open links first (so they close after styles that start at the same position) + const openingItems: OpeningItem[] = []; + const openingLinks = linkStarts.get(pos); if (openingLinks && openingLinks.length > 0) { - for (const link of openingLinks) { - out += link.open; - stack.push({ close: link.close, end: link.end }); + for (const [index, link] of openingLinks.entries()) { + openingItems.push({ + end: link.end, + open: link.open, + close: link.close, + kind: "link", + index, + }); } } - // Open styles second (so they close before links that start at the same position) const openingStyles = startsAt.get(pos); if (openingStyles) { - for (const span of openingStyles) { + for (const [index, span] of openingStyles.entries()) { const marker = styleMarkers[span.style]; if (!marker) continue; - out += marker.open; - stack.push({ close: marker.close, end: span.end }); + openingItems.push({ + end: span.end, + open: marker.open, + close: marker.close, + kind: "style", + style: span.style, + index, + }); + } + } + + if (openingItems.length > 0) { + openingItems.sort((a, b) => { + if (a.end !== b.end) return b.end - a.end; + if (a.kind !== b.kind) return a.kind === "link" ? -1 : 1; + if (a.kind === "style" && b.kind === "style") { + return (STYLE_RANK.get(a.style) ?? 0) - (STYLE_RANK.get(b.style) ?? 0); + } + return a.index - b.index; + }); + + // Open outer spans first (larger end) so LIFO closes stay valid for same-start overlaps. + for (const item of openingItems) { + out += item.open; + stack.push({ close: item.close, end: item.end }); } } diff --git a/src/telegram/format.test.ts b/src/telegram/format.test.ts index fbc811b72..e267719a8 100644 --- a/src/telegram/format.test.ts +++ b/src/telegram/format.test.ts @@ -59,4 +59,14 @@ describe("markdownToTelegramHtml", () => { const res = markdownToTelegramHtml("**bold [link](https://example.com) text**"); expect(res).toBe('bold link text'); }); + + it("properly nests bold wrapping a link with trailing text", () => { + const res = markdownToTelegramHtml("**[link](https://example.com) rest**"); + expect(res).toBe('link rest'); + }); + + it("properly nests bold inside a link", () => { + const res = markdownToTelegramHtml("[**bold**](https://example.com)"); + expect(res).toBe('bold'); + }); }); From 37e295fc029070a214e2690d08429791fcac1512 Mon Sep 17 00:00:00 2001 From: Ayush Ojha Date: Fri, 30 Jan 2026 07:39:17 -0800 Subject: [PATCH 6/7] fix: don't warn about expired OAuth tokens with valid refresh tokens (#4593) OAuth credentials with a refresh token auto-renew on first API call, so the doctor should not warn about access token expiration when a refresh token is present. This avoids unnecessary "expired" warnings that prompt users to re-auth when no action is needed. Fixes #3032 Co-authored-by: Ayush Ojha --- src/agents/auth-health.test.ts | 34 +++++++++++++++++++++++++++++++--- src/agents/auth-health.ts | 11 ++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/agents/auth-health.test.ts b/src/agents/auth-health.test.ts index a7797e17e..80b38a6c6 100644 --- a/src/agents/auth-health.test.ts +++ b/src/agents/auth-health.test.ts @@ -52,11 +52,39 @@ describe("buildAuthHealthSummary", () => { ); expect(statuses["anthropic:ok"]).toBe("ok"); - expect(statuses["anthropic:expiring"]).toBe("expiring"); - expect(statuses["anthropic:expired"]).toBe("expired"); + // OAuth credentials with refresh tokens are auto-renewable, so they report "ok" + expect(statuses["anthropic:expiring"]).toBe("ok"); + expect(statuses["anthropic:expired"]).toBe("ok"); expect(statuses["anthropic:api"]).toBe("static"); const provider = summary.providers.find((entry) => entry.provider === "anthropic"); - expect(provider?.status).toBe("expired"); + expect(provider?.status).toBe("ok"); + }); + + it("reports expired for OAuth without a refresh token", () => { + vi.spyOn(Date, "now").mockReturnValue(now); + const store = { + version: 1, + profiles: { + "google:no-refresh": { + type: "oauth" as const, + provider: "google-antigravity", + access: "access", + refresh: "", + expires: now - 10_000, + }, + }, + }; + + const summary = buildAuthHealthSummary({ + store, + warnAfterMs: DEFAULT_OAUTH_WARN_MS, + }); + + const statuses = Object.fromEntries( + summary.profiles.map((profile) => [profile.profileId, profile.status]), + ); + + expect(statuses["google:no-refresh"]).toBe("expired"); }); }); diff --git a/src/agents/auth-health.ts b/src/agents/auth-health.ts index c039d81be..4f281b7bb 100644 --- a/src/agents/auth-health.ts +++ b/src/agents/auth-health.ts @@ -123,7 +123,16 @@ function buildProfileHealth(params: { }; } - const { status, remainingMs } = resolveOAuthStatus(credential.expires, now, warnAfterMs); + const hasRefreshToken = typeof credential.refresh === "string" && credential.refresh.length > 0; + const { status: rawStatus, remainingMs } = resolveOAuthStatus( + credential.expires, + now, + warnAfterMs, + ); + // OAuth credentials with a valid refresh token auto-renew on first API call, + // so don't warn about access token expiration. + const status = + hasRefreshToken && (rawStatus === "expired" || rawStatus === "expiring") ? "ok" : rawStatus; return { profileId, provider: credential.provider, From 3fbf99d725d2a971aad9fabafd37340809ed3e4b Mon Sep 17 00:00:00 2001 From: Yuting Lin Date: Fri, 30 Jan 2026 13:28:07 +0000 Subject: [PATCH 7/7] fix(line): resolve TypeError in status command --- extensions/line/src/channel.ts | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index b552b7ea7..fee6d94cd 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -4,6 +4,7 @@ import { LineConfigSchema, processLineMessage, type ChannelPlugin, + type ChannelStatusIssue, type OpenClawConfig, type LineConfig, type LineChannelData, @@ -560,19 +561,26 @@ export const linePlugin: ChannelPlugin = { lastStopAt: null, lastError: null, }, - collectStatusIssues: ({ account }) => { - const issues: Array<{ level: "error" | "warning"; message: string }> = []; - if (!account.channelAccessToken?.trim()) { - issues.push({ - level: "error", - message: "LINE channel access token not configured", - }); - } - if (!account.channelSecret?.trim()) { - issues.push({ - level: "error", - message: "LINE channel secret not configured", - }); + collectStatusIssues: (accounts) => { + const issues: ChannelStatusIssue[] = []; + for (const account of accounts) { + const accountId = account.accountId ?? DEFAULT_ACCOUNT_ID; + if (!account.channelAccessToken?.trim()) { + issues.push({ + channel: "line", + accountId, + kind: "config", + message: "LINE channel access token not configured", + }); + } + if (!account.channelSecret?.trim()) { + issues.push({ + channel: "line", + accountId, + kind: "config", + message: "LINE channel secret not configured", + }); + } } return issues; },