From ef1ffacfb2bc2b3e790ab0aaa4fd3bd6e243a678 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 24 Feb 2026 02:53:30 +0000 Subject: [PATCH 001/408] scripts: exclude unresolved clawtributors from README --- scripts/update-clawtributors.ts | 116 ++++++++++++-------------------- 1 file changed, 44 insertions(+), 72 deletions(-) diff --git a/scripts/update-clawtributors.ts b/scripts/update-clawtributors.ts index 77724d2b0193..0e106e65969d 100644 --- a/scripts/update-clawtributors.ts +++ b/scripts/update-clawtributors.ts @@ -15,7 +15,6 @@ const emailToLogin = normalizeMap(mapConfig.emailToLogin ?? {}); const ensureLogins = (mapConfig.ensureLogins ?? []).map((login) => login.toLowerCase()); const readmePath = resolve("README.md"); -const placeholderAvatar = mapConfig.placeholderAvatar ?? "assets/avatar-placeholder.svg"; const seedCommit = mapConfig.seedCommit ?? null; const seedEntries = seedCommit ? parseReadmeEntries(run(`git show ${seedCommit}:README.md`)) : []; const raw = run(`gh api "repos/${REPO}/contributors?per_page=100&anon=1" --paginate`); @@ -98,33 +97,33 @@ for (const login of ensureLogins) { const entriesByKey = new Map(); for (const seed of seedEntries) { - const login = loginFromUrl(seed.html_url); - const resolvedLogin = - login ?? resolveLogin(seed.display, null, apiByLogin, nameToLogin, emailToLogin); - const key = resolvedLogin ? resolvedLogin.toLowerCase() : `name:${normalizeName(seed.display)}`; - const avatar = - seed.avatar_url && !isGhostAvatar(seed.avatar_url) - ? normalizeAvatar(seed.avatar_url) - : placeholderAvatar; + const login = + loginFromUrl(seed.html_url) ?? + resolveLogin(seed.display, null, apiByLogin, nameToLogin, emailToLogin); + if (!login) { + continue; + } + const key = login.toLowerCase(); + const user = apiByLogin.get(key) ?? fetchUser(login); + if (!user) { + continue; + } + apiByLogin.set(key, user); const existing = entriesByKey.get(key); if (!existing) { - const user = resolvedLogin ? apiByLogin.get(key) : null; entriesByKey.set(key, { key, - login: resolvedLogin ?? login ?? undefined, + login: user.login, display: seed.display, - html_url: user?.html_url ?? seed.html_url, - avatar_url: user?.avatar_url ?? avatar, + html_url: user.html_url, + avatar_url: user.avatar_url, lines: 0, }); } else { existing.display = existing.display || seed.display; - if (existing.avatar_url === placeholderAvatar || !existing.avatar_url) { - existing.avatar_url = avatar; - } - if (!existing.html_url || existing.html_url.includes("/search?q=")) { - existing.html_url = seed.html_url; - } + existing.login = user.login; + existing.html_url = user.html_url; + existing.avatar_url = user.avatar_url; } } @@ -138,52 +137,37 @@ for (const item of contributors) { ? item.login : resolveLogin(baseName, item.email ?? null, apiByLogin, nameToLogin, emailToLogin); - if (resolvedLogin) { - const key = resolvedLogin.toLowerCase(); - const existing = entriesByKey.get(key); - if (!existing) { - let user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); - if (user) { - const lines = linesByLogin.get(key) ?? 0; - const contributions = contributionsByLogin.get(key) ?? 0; - entriesByKey.set(key, { - key, - login: user.login, - display: pickDisplay(baseName, user.login, existing?.display), - html_url: user.html_url, - avatar_url: normalizeAvatar(user.avatar_url), - lines: lines > 0 ? lines : contributions, - }); - } - } else if (existing) { - existing.login = existing.login ?? resolvedLogin; - existing.display = pickDisplay(baseName, existing.login, existing.display); - if (existing.avatar_url === placeholderAvatar || !existing.avatar_url) { - const user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); - if (user) { - existing.html_url = user.html_url; - existing.avatar_url = normalizeAvatar(user.avatar_url); - } - } - const lines = linesByLogin.get(key) ?? 0; - const contributions = contributionsByLogin.get(key) ?? 0; - existing.lines = Math.max(existing.lines, lines > 0 ? lines : contributions); - } + if (!resolvedLogin) { + continue; + } + + const key = resolvedLogin.toLowerCase(); + const user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); + if (!user) { continue; } + apiByLogin.set(key, user); - const anonKey = `name:${normalizeName(baseName)}`; - const existingAnon = entriesByKey.get(anonKey); - if (!existingAnon) { - entriesByKey.set(anonKey, { - key: anonKey, - display: baseName, - html_url: fallbackHref(baseName), - avatar_url: placeholderAvatar, - lines: item.contributions ?? 0, + const existing = entriesByKey.get(key); + if (!existing) { + const lines = linesByLogin.get(key) ?? 0; + const contributions = contributionsByLogin.get(key) ?? 0; + entriesByKey.set(key, { + key, + login: user.login, + display: pickDisplay(baseName, user.login), + html_url: user.html_url, + avatar_url: normalizeAvatar(user.avatar_url), + lines: lines > 0 ? lines : contributions, }); } else { - existingAnon.lines = Math.max(existingAnon.lines, item.contributions ?? 0); + existing.login = user.login; + existing.display = pickDisplay(baseName, user.login, existing.display); + existing.html_url = user.html_url; + existing.avatar_url = normalizeAvatar(user.avatar_url); + const lines = linesByLogin.get(key) ?? 0; + const contributions = contributionsByLogin.get(key) ?? 0; + existing.lines = Math.max(existing.lines, lines > 0 ? lines : contributions); } } @@ -205,14 +189,6 @@ for (const [login, lines] of linesByLogin.entries()) { avatar_url: normalizeAvatar(user.avatar_url), lines: lines > 0 ? lines : contributions, }); - } else { - entriesByKey.set(login, { - key: login, - display: login, - html_url: fallbackHref(login), - avatar_url: placeholderAvatar, - lines, - }); } } @@ -323,10 +299,6 @@ function normalizeAvatar(url: string): string { return `${url}${sep}s=48`; } -function isGhostAvatar(url: string): boolean { - return url.toLowerCase().includes("ghost.png"); -} - function fetchUser(login: string): User | null { const normalized = normalizeLogin(login); if (!normalized) { From 71f4b93656e29700997e9458042eeef04cb405f1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 24 Feb 2026 02:54:19 +0000 Subject: [PATCH 002/408] docs: refresh clawtributors list --- README.md | 124 ++++++++++++++++++++++-------------------------------- 1 file changed, 50 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 72f362418d72..3cc1bacfc3fb 100644 --- a/README.md +++ b/README.md @@ -503,78 +503,54 @@ Special thanks to Adam Doppelt for lobster.bot. Thanks to all clawtributors:

- steipete sktbrd cpojer joshp123 sebslight Mariano Belinky Takhoffman tyler6204 quotentiroler Verite Igiraneza - bohdanpodvirnyi gumadeiras iHildy jaydenfyi joaohlisboa rodrigouroz Glucksberg mneves75 MatthieuBizien MaudeBot - vignesh07 vincentkoc smartprogrammer93 advaitpaliwal HenryLoenwind rahthakor vrknetha abdelsfane radek-paclt joshavant - christianklotz zerone0x ranausmanai Tobias Bischoff heyhudson czekaj ethanpalm mukhtharcm yinghaosang aether-ai-agent - nabbilkhan Mrseenz maxsumrall coygeek xadenryan VACInc juanpablodlc conroywhitney buerbaumer Bridgerz - hsrvc magimetal openclaw-bot meaningfool mudrii JustasM ENCHIGO patelhiren NicholasSpisak claude - jonisjongithub abhisekbasu1 theonejvo Blakeshannon jamesgroat Marvae BunsDev shakkernerd gejifeng akoscz - divanoli ryan-crabbe nyanjou Sam Padilla dantelex SocialNerd42069 solstead natefikru daveonkels LeftX - Yida-Dev Masataka Shinohara arosstale riccardogiorato lc0rp adam91holt mousberg BillChirico shadril238 CharlieGreenman - hougangdev orlyjamie McRolly NWANGWU durenzidu JustYannicc Minidoracat magendary jessy2027 mteam88 hirefrank - M00N7682 dbhurley Eng. Juan Combetto Harrington-bot TSavo Lalit Singh julianengel jscaldwell55 bradleypriest TsekaLuk - benithors Shailesh loiie45e El-Fitz benostein pvtclawn thewilloftheshadow nachx639 0xRaini Taylor Asplund - Paul van Oorschot sreekaransrinath buddyh gupsammy AI-Reviewer-QS Stefan Galescu WalterSumbon nachoiacovino xinhuagu brandonwise - rodbland2021 Vasanth Rao Naik Sabavat fagemx petter-b leszekszpunar davidrudduck Jackten scald pycckuu Parker Todd Brooks - simonemacario omair445 AnonO6 Tanwa Arpornthip andranik-sahakyan davidguttman sleontenko denysvitali Tom Ron popomore - Patrick Barletta shayan919293 不做了睡大觉 Lucky Michael Lee sircrumpet peschee dakshaymehta nicolasstanley davidiach - nonggia.liang seheepeak danielwanwx hudson-rivera misterdas Shuai-DaiDai dominicnunez obviyus lploc94 sfo2001 - lutr0 dirbalak cathrynlavery kiranjd danielz1z Iranb cdorsey AdeboyeDN j2h4u Alg0rix - Skyler Miao peetzweg/ TideFinder Clawborn emanuelst bsormagec Diaspar4u evanotero Nate OscarMinjarez - webvijayi garnetlyx jlowin liebertar Max rhuanssauro joshrad-dev osolmaz adityashaw2 CashWilliams - sheeek asklee-klawd h0tp-ftw constansino Mitsuyuki Osabe onutc ryan artuskg Solvely-Colin mcaxtr - HirokiKobayashi-R taw0002 Kimitaka Watanabe Lilo Rajat Joshi Yuting Lin Neo Thorfinn wu-tian807 crimeacs - manuelhettich mcinteerj unisone bjesuiter Manik Vahsith alexgleason Nicholas Stephen Brian King mahanandhi andreesg - connorshea dinakars777 divisonofficer Flash-LHR Protocol Zero kyleok Limitless slonce70 grp06 robbyczgw-cla - JayMishra-source ngutman ide-rea badlogic lailoo amitbiswal007 azade-c John-Rood Iron9521 roshanasingh4 - tosh-hamburg dlauer ezhikkk Shivam Kumar Raut jabezborja Mykyta Bozhenko YuriNachos Josh Phillips Wangnov jadilson12 - 康熙 akramcodez clawdinator[bot] emonty kaizen403 Whoaa512 chriseidhof wangai-studio ysqander Yurii Chukhlib - 17jmumford aj47 google-labs-jules[bot] hyf0-agent Kenny Lee Lukavyi Operative-001 superman32432432 DylanWoodAkers Hisleren - widingmarcus-cyber antons austinm911 boris721 damoahdominic dan-dr doodlewind GHesericsu HeimdallStrategy imfing - jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf Randy Torres Ryan Lisse sumleo Yeom-JinHo zisisp - akyourowngames aldoeliacim Dithilli dougvk erikpr1994 fal3 Ghost jonasjancarik Keith the Silly Goose koala73 - L36 Server Marc mitschabaude-bot mkbehr Oren Rain shtse8 sibbl thesomewhatyou zats - chrisrodz echoVic Friederike Seiler gabriel-trigo ghsmc iamadig ibrahimq21 irtiq7 jeann2013 jogelin - Jonathan D. Rhyne (DJ-D) Joshua Mitchell Justin Ling kelvinCB Kit manmal MattQ Milofax mitsuhiko neist - pejmanjohn Ralph rmorse rubyrunsstuff rybnikov Steve (OpenClaw) suminhthanh svkozak wes-davis 24601 - AkashKobal ameno- awkoy BinHPdev bonald Chris Taylor dawondyifraw dguido Django Navarro evalexpr - henrino3 humanwritten hyojin joeykrug justinhuangcode larlyssa liuy ludd50155 Mark Liu natedenh - odysseus0 pcty-nextgen-service-account pi0 Roopak Nijhara Sean McLellan Syhids tmchow Ubuntu uli-will-code xiaose - Aaron Konyer aaronveklabs Aditya Singh andreabadesso Andrii battman21 BinaryMuse cash-echo-bot CJWTRUST Clawd - Clawdbot ClawdFx cordx56 danballance Elarwei001 EnzeD erik-agens Evizero fcatuhe gildo - Grynn hanxiao Ignacio itsjaydesu ivancasco ivanrvpereira Jarvis jayhickey jeffersonwarrior jeffersonwarrior - jverdi kentaro loeclos longmaba Marco Marandiz MarvinCui mjrussell odnxe optimikelabs oswalpalash - p6l-richard philipp-spiess Pocket Clawd RamiNoodle733 Raymond Berger Rob Axelsen Sash Catanzarite sauerdaniel Sriram Naidu Thota T5-AndyML - thejhinvirtuoso travisp VAC william arzt Yao yudshj zknicker 尹凯 {Suksham-sharma} 0oAstro - 8BlT Abdul535 abhaymundhara abhijeet117 aduk059 afurm aisling404 akari-musubi alejandro maza Alex-Alaniz - alexanderatallah alexstyl AlexZhangji amabito andrewting19 anisoptera araa47 arthyn Asleep123 Ayush Ojha - Ayush10 baccula beefiker bennewton999 bguidolim blacksmith-sh[bot] bqcfjwhz85-arch bravostation Buddy (AI) caelum0x - calvin-hpnet championswimmer chenglun.hu Chloe-VP Claw Clawdbot Maintainers cristip73 danielcadenhead dario-github DarwinsBuddy - David-Marsh-Photo davidbors-snyk dcantu96 dependabot[bot] Developer Dimitrios Ploutarchos Drake Thomsen dvrshil dxd5001 dylanneve1 - elliotsecops EmberCF ereid7 eternauta1337 f-trycua fan Felix Krause foeken frankekn fujiwara-tofu-shop - ganghyun kim gaowanqi08141999 gerardward2007 gitpds gtsifrikas habakan HassanFleyah HazAT hcl headswim - hlbbbbbbb Hubert hugobarauna hyaxia iamEvanYT ikari ikari-pl Iron ironbyte-rgb Ítalo Souza - Jamie Openshaw Jane Jarvis Deploy jarvis89757 jasonftl jasonsschin Jefferson Nunn jg-noncelogic jigar joeynyc - Jon Uleis Josh Long justyannicc Karim Naguib Kasper Neist Christjansen Keshav Rao Kevin Lin Kira knocte Knox - Kristijan Jovanovski Kyle Chen Latitude Bot Levi Figueira Liu Weizhan Lloyd Loganaden Velvindron lsh411 Lucas Kim Luka Zhang - Lukáš Loukota Lukin mac mimi mac26ai MackDing Mahsum Aktas Marc Beaupre Marcus Neves Mario Zechner Markus Buhatem Koch - Martin Púčik Martin Schürrer MarvinDontPanic Mateusz Michalik Matias Wainsten Matt Ezell Matt mini Matthew Dicembrino Mauro Bolis mcwigglesmcgee - meaadore1221-afk Mert Çiçekçi Michael Verrilli Miles minghinmatthewlam Mourad Boustani Mr. Guy Mustafa Tag Eldeen myfunc Nate - Nathaniel Kelner Netanel Draiman niceysam Nick Lamb Nick Taylor Nikolay Petrov NM nobrainer-tech Noctivoro norunners - Ocean Vael Ogulcan Celik Oleg Kossoy Olshansk Omar Khaleel OpenClaw Agent Ozgur Polat Pablo Nunez Palash Oswal pasogott - Patrick Shao Paul Pamment Paulo Portella Peter Lee Petra Donka Pham Nam pierreeurope pip-nomel plum-dawg pookNast - Pratham Dubey Quentin rafaelreis-r Raikan10 Ramin Shirali Hossein Zade Randy Torres Raphael Borg Ellul Vincenti Ratul Sarna Richard Pinedo Rick Qian - robhparker Rohan Nagpal Rohan Patil rohanpatriot Rolf Fredheim Rony Kelner Ryan Nelson Samrat Jha Santosh Sascha Reuter - Saurabh.Chopade saurav470 seans-openclawbot SecondThread seewhy Senol Dogan Sergiy Dybskiy Shadow shatner Shaun Loo - Shaun Mason Shiva Prasad Shrinija Kummari Siddhant Jain Simon Kelly SK Heavy Industries sldkfoiweuaranwdlaiwyeoaw Soumyadeep Ghosh Spacefish spiceoogway - Stephen Chen Steve succ985 Suksham Sunwoo Yu Suvin Nimnaka Swader swizzmagik Tag techboss - testingabc321 tewatia The Admiral therealZpoint-bot tian Xiao Tim Krase Timo Lins Tom McKenzie Tom Peri Tomas Hajek - Tomsun28 Tonic Travis Hinton Travis Irby Tulsi Prasad Ty Sabs Tyler uos-status Vai Varun Kruthiventi - Vibe Kanban Victor Castell victor-wu.eth vikpos Vincent VintLin Vladimir Peshekhonov void Vultr-Clawd Admin William Stock - williamtwomey Wimmie Winry Winston wolfred Xin Xinhe Hu Xu Haoran Yash Yaxuan42 - Yazin Yevhen Bobrov Yi Wang ymat19 Yuan Chen Yuanhai Zach Knickerbocker Zaf (via OpenClaw) zhixian 石川 諒 - 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik hrdwdmrbl jiulingyun - kitze latitudeki5223 loukotal Manuel Maly minghinmatthewlam MSch odrobnik pcty-nextgen-ios-builder rafaelreis-r ratulsarna - reeltimeapps rhjoh ronak-guliani snopoke thesash timkrase + steipete sktbrd cpojer joshp123 Mariano Belinky Takhoffman sebslight tyler6204 quotentiroler Verite Igiraneza + gumadeiras bohdanpodvirnyi vincentkoc iHildy jaydenfyi Glucksberg joaohlisboa rodrigouroz mneves75 BunsDev + MatthieuBizien MaudeBot vignesh07 smartprogrammer93 advaitpaliwal HenryLoenwind rahthakor vrknetha abdelsfane radek-paclt + joshavant christianklotz mudrii zerone0x ranausmanai Tobias Bischoff heyhudson czekaj ethanpalm yinghaosang + nabbilkhan mukhtharcm aether-ai-agent coygeek Mrseenz maxsumrall xadenryan VACInc juanpablodlc conroywhitney + Harald Buerbaumer akoscz Bridgerz hsrvc magimetal openclaw-bot meaningfool JustasM Phineas1500 ENCHIGO + Hiren Patel NicholasSpisak claude jonisjongithub theonejvo abhisekbasu1 Ryan Haines Blakeshannon jamesgroat Marvae + arosstale shakkernerd gejifeng divanoli ryan-crabbe nyanjou Sam Padilla dantelex SocialNerd42069 solstead + natefikru daveonkels LeftX Yida-Dev Masataka Shinohara Lewis riccardogiorato lc0rp adam91holt mousberg + BillChirico shadril238 CharlieGreenman hougangdev Mars orlyjamie McRolly NWANGWU LI SHANXIN Simone Macario durenzidu + JustYannicc Minidoracat magendary Jessy LANGE mteam88 brandonwise hirefrank M00N7682 dbhurley Eng. Juan Combetto + Harrington-bot TSavo Lalit Singh julianengel Jay Caldwell Kirill Shchetynin nachx639 bradleypriest TsekaLuk benithors + Shailesh thewilloftheshadow jackheuberger loiie45e El-Fitz benostein pvtclawn 0xRaini ruypang xinhuagu + Taylor Asplund adhitShet Paul van Oorschot sreekaransrinath buddyh gupsammy AI-Reviewer-QS Stefan Galescu WalterSumbon nachoiacovino + rodbland2021 Vasanth Rao Naik Sabavat fagemx petter-b omair445 dorukardahan leszekszpunar Clawborn davidrudduck scald + Igor Markelov rrenamed Parker Todd Brooks AnonO6 Tanwa Arpornthip andranik-sahakyan davidguttman sleontenko denysvitali Tom Ron + popomore Patrick Barletta shayan919293 不做了睡大觉 Luis Conde Harry Cui Kepler SidQin-cyber Lucky Michael Lee sircrumpet + peschee dakshaymehta davidiach nonggia.liang seheepeak obviyus danielwanwx osolmaz minupla misterdas + Shuai-DaiDai dominicnunez lploc94 sfo2001 lutr0 dirbalak cathrynlavery Joly0 kiranjd niceysam + danielz1z Iranb carrotRakko Oceanswave cdorsey AdeboyeDN j2h4u Alg0rix Skyler Miao peetzweg/ + TideFinder CornBrother0x DukeDeSouth emanuelst bsormagec Diaspar4u evanotero Nate OscarMinjarez webvijayi + garnetlyx miloudbelarebia Jeremiah Lowin liebertar Max rhuanssauro joshrad-dev adityashaw2 CashWilliams taw0002 + asklee-klawd h0tp-ftw constansino mcaxtr onutc ryan unisone artuskg Solvely-Colin pahdo + Kimitaka Watanabe Lilo Rajat Joshi Yuting Lin Neo wu-tian807 ngutman crimeacs manuelhettich mcinteerj + bjesuiter Manik Vahsith alexgleason Nicholas Stephen Brian King justinhuangcode mahanandhi andreesg connorshea dinakars777 + Flash-LHR JINNYEONG KIM Protocol Zero kyleok Limitless grp06 robbyczgw-cla slonce70 JayMishra-source ide-rea + lailoo badlogic echoVic amitbiswal007 azade-c John Rood dddabtc Jonathan Works roshanasingh4 tosh-hamburg + dlauer ezhikkk Shivam Kumar Raut Mykyta Bozhenko YuriNachos Josh Phillips ThomsenDrake Wangnov akramcodez jadilson12 + Whoaa512 clawdinator[bot] emonty kaizen403 chriseidhof Lukavyi wangai-studio ysqander aj47 google-labs-jules[bot] + hyf0-agent Jeremy Mumford Kenny Lee superman32432432 widingmarcus-cyber DylanWoodAkers antons austinm911 boris721 damoahdominic + dan-dr doodlewind GHesericsu HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf + Randy Torres sumleo Yeom-JinHo akyourowngames aldoeliacim Dithilli dougvk erikpr1994 fal3 jonasjancarik + koala73 mitschabaude-bot mkbehr Oren shtse8 sibbl thesomewhatyou zats chrisrodz frankekn + gabriel-trigo ghsmc iamadig ibrahimq21 irtiq7 jeann2013 jogelin Jonathan D. Rhyne (DJ-D) Justin Ling kelvinCB + manmal Matthew MattQ Milofax mitsuhiko neist pejmanjohn ProspectOre rmorse rubyrunsstuff + rybnikov santiagomed Steve (OpenClaw) suminhthanh svkozak wes-davis 24601 AkashKobal ameno- awkoy + battman21 BinHPdev bonald dashed dawondyifraw dguido Django Navarro evalexpr henrino3 humanwritten + hyojin joeykrug larlyssa liuy Mark Liu natedenh odysseus0 pcty-nextgen-service-account pi0 Syhids + tmchow uli-will-code aaronveklabs andreabadesso BinaryMuse cash-echo-bot CJWTRUST cordx56 danballance Elarwei001 + EnzeD erik-agens Evizero fcatuhe gildo Grynn huntharo hydro13 itsjaydesu ivanrvpereira + jverdi kentaro loeclos longmaba MarvinCui MisterGuy420 mjrussell odnxe optimikelabs oswalpalash + p6l-richard philipp-spiess RamiNoodle733 Raymond Berger Rob Axelsen sauerdaniel SleuthCo T5-AndyML TaKO8Ki thejhinvirtuoso + travisp yudshj zknicker 0oAstro 8BlT Abdul535 abhaymundhara aduk059 afurm aisling404 + akari-musubi Alex-Alaniz alexanderatallah alexstyl andrewting19 araa47 Asleep123 Ayush10 bennewton999 bguidolim + caelum0x championswimmer Chloe-VP dario-github DarwinsBuddy David-Marsh-Photo dcantu96 dndodson dvrshil dxd5001 + dylanneve1 EmberCF ephraimm ereid7 eternauta1337 foeken gtsifrikas HazAT iamEvanYT ikari-pl + kesor knocte MackDing nobrainer-tech Noctivoro Olshansk Pratham Dubey Raikan10 SecondThread Swader + testingabc321 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou carlulsoe hrdwdmrbl hugobarauna jayhickey jiulingyun + kitze latitudeki5223 loukotal minghinmatthewlam MSch odrobnik rafaelreis-r ratulsarna reeltimeapps rhjoh + ronak-guliani snopoke thesash timkrase

From ff10fe8b91670044a6bb0cd85deb736a0ec8fb55 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 02:58:15 +0000 Subject: [PATCH 003/408] fix(security): require /etc/shells for shell env fallback --- src/infra/shell-env.test.ts | 37 ++++++++++++++++++++++++++++++++----- src/infra/shell-env.ts | 23 +---------------------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/infra/shell-env.test.ts b/src/infra/shell-env.test.ts index 80eda1da5809..ab06202dc20b 100644 --- a/src/infra/shell-env.test.ts +++ b/src/infra/shell-env.test.ts @@ -28,6 +28,7 @@ describe("shell env fallback", () => { } function runShellEnvFallbackForShell(shell: string) { + resetShellPathCacheForTests(); const env: NodeJS.ProcessEnv = { SHELL: shell }; const exec = vi.fn(() => Buffer.from("OPENAI_API_KEY=from-shell\0")); const res = loadShellEnvFallback({ @@ -170,18 +171,44 @@ describe("shell env fallback", () => { expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object)); }); - it("uses trusted absolute SHELL path when executable on posix-style paths", () => { - const accessSyncSpy = vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); + it("falls back to /bin/sh when SHELL is absolute but not registered in /etc/shells", () => { + const readFileSyncSpy = vi + .spyOn(fs, "readFileSync") + .mockImplementation((filePath, encoding) => { + if (filePath === "/etc/shells" && encoding === "utf8") { + return "/bin/sh\n/bin/bash\n/bin/zsh\n"; + } + throw new Error(`Unexpected readFileSync(${String(filePath)}) in test`); + }); + try { + const { res, exec } = runShellEnvFallbackForShell("/opt/homebrew/bin/evil-shell"); + + expect(res.ok).toBe(true); + expect(exec).toHaveBeenCalledTimes(1); + expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object)); + } finally { + readFileSyncSpy.mockRestore(); + } + }); + + it("uses SHELL when it is explicitly registered in /etc/shells", () => { + const readFileSyncSpy = vi + .spyOn(fs, "readFileSync") + .mockImplementation((filePath, encoding) => { + if (filePath === "/etc/shells" && encoding === "utf8") { + return "/bin/sh\n/usr/bin/zsh-trusted\n"; + } + throw new Error(`Unexpected readFileSync(${String(filePath)}) in test`); + }); try { const trustedShell = "/usr/bin/zsh-trusted"; const { res, exec } = runShellEnvFallbackForShell(trustedShell); - const expectedShell = process.platform === "win32" ? "/bin/sh" : trustedShell; expect(res.ok).toBe(true); expect(exec).toHaveBeenCalledTimes(1); - expect(exec).toHaveBeenCalledWith(expectedShell, ["-l", "-c", "env -0"], expect.any(Object)); + expect(exec).toHaveBeenCalledWith(trustedShell, ["-l", "-c", "env -0"], expect.any(Object)); } finally { - accessSyncSpy.mockRestore(); + readFileSyncSpy.mockRestore(); } }); diff --git a/src/infra/shell-env.ts b/src/infra/shell-env.ts index 30f255cbce64..ac1369c48be5 100644 --- a/src/infra/shell-env.ts +++ b/src/infra/shell-env.ts @@ -8,13 +8,6 @@ import { sanitizeHostExecEnv } from "./host-env-security.js"; const DEFAULT_TIMEOUT_MS = 15_000; const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024; const DEFAULT_SHELL = "/bin/sh"; -const TRUSTED_SHELL_PREFIXES = [ - "/bin/", - "/usr/bin/", - "/usr/local/bin/", - "/opt/homebrew/bin/", - "/run/current-system/sw/bin/", -]; let lastAppliedKeys: string[] = []; let cachedShellPath: string | null | undefined; let cachedEtcShells: Set | null | undefined; @@ -70,21 +63,7 @@ function isTrustedShellPath(shell: string): boolean { // Primary trust anchor: shell registered in /etc/shells. const registeredShells = readEtcShells(); - if (registeredShells?.has(shell)) { - return true; - } - - // Fallback for environments where /etc/shells is incomplete/unavailable. - if (!TRUSTED_SHELL_PREFIXES.some((prefix) => shell.startsWith(prefix))) { - return false; - } - - try { - fs.accessSync(shell, fs.constants.X_OK); - return true; - } catch { - return false; - } + return registeredShells?.has(shell) === true; } function resolveShell(env: NodeJS.ProcessEnv): string { From d0ef4c75c7eb19ae562587c9d0a9afb3beec9560 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 02:59:10 +0000 Subject: [PATCH 004/408] docs(changelog): credit safeBins advisory reporters --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c30bc75e3c..c25761ec4c8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ Docs: https://docs.openclaw.ai - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution. -- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. Thanks @jiseoung. +- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting. - Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings. - Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. From 60f1d1959aa05eb08348c9b32e091e7b074e5692 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:02:32 +0000 Subject: [PATCH 005/408] test: stabilize invoke-system-run env-wrapper assertion on Windows --- src/node-host/invoke-system-run.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index bffe6c638bae..410382a5aad0 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -120,12 +120,25 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { ); }); - it("runs canonical argv in allowlist mode for transparent env wrappers", async () => { + it("handles transparent env wrappers in allowlist mode", async () => { const { runCommand, sendInvokeResult } = await runSystemInvoke({ preferMacAppExecHost: false, security: "allowlist", command: ["env", "tr", "a", "b"], }); + if (process.platform === "win32") { + expect(runCommand).not.toHaveBeenCalled(); + expect(sendInvokeResult).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: expect.stringContaining("allowlist miss"), + }), + }), + ); + return; + } + expect(runCommand).toHaveBeenCalledWith(["tr", "a", "b"], undefined, undefined, undefined); expect(sendInvokeResult).toHaveBeenCalledWith( expect.objectContaining({ From c5ac90ab92fbb9949acb8335f33e672f94646198 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:04:43 +0000 Subject: [PATCH 006/408] docs(changelog): add shell-env fallback hardening note --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c25761ec4c8c..3a712c74de66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. From 9530c0108589b4a956ebf0338d7ae69648fd1d8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:04:57 +0000 Subject: [PATCH 007/408] refactor(exec): split safe-bin policy modules and dedupe allowlist flow --- src/infra/exec-approvals-allowlist.ts | 65 ++- src/infra/exec-safe-bin-policy-profiles.ts | 315 +++++++++++++ src/infra/exec-safe-bin-policy-validator.ts | 206 +++++++++ src/infra/exec-safe-bin-policy.ts | 487 +------------------- 4 files changed, 566 insertions(+), 507 deletions(-) create mode 100644 src/infra/exec-safe-bin-policy-profiles.ts create mode 100644 src/infra/exec-safe-bin-policy-validator.ts diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index 6d48347e4031..bff632d46be0 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -178,6 +178,13 @@ function evaluateSegments( return { satisfied, matches, segmentSatisfiedBy }; } +function resolveAnalysisSegmentGroups(analysis: ExecCommandAnalysis): ExecCommandSegment[][] { + if (analysis.chains) { + return analysis.chains; + } + return [analysis.segments]; +} + export function evaluateExecAllowlist(params: { analysis: ExecCommandAnalysis; allowlist: ExecAllowlistEntry[]; @@ -195,44 +202,32 @@ export function evaluateExecAllowlist(params: { return { allowlistSatisfied: false, allowlistMatches, segmentSatisfiedBy }; } - // If the analysis contains chains, evaluate each chain part separately - if (params.analysis.chains) { - for (const chainSegments of params.analysis.chains) { - const result = evaluateSegments(chainSegments, { - allowlist: params.allowlist, - safeBins: params.safeBins, - safeBinProfiles: params.safeBinProfiles, - cwd: params.cwd, - platform: params.platform, - trustedSafeBinDirs: params.trustedSafeBinDirs, - skillBins: params.skillBins, - autoAllowSkills: params.autoAllowSkills, - }); - if (!result.satisfied) { - return { allowlistSatisfied: false, allowlistMatches: [], segmentSatisfiedBy: [] }; + const hasChains = Boolean(params.analysis.chains); + for (const group of resolveAnalysisSegmentGroups(params.analysis)) { + const result = evaluateSegments(group, { + allowlist: params.allowlist, + safeBins: params.safeBins, + safeBinProfiles: params.safeBinProfiles, + cwd: params.cwd, + platform: params.platform, + trustedSafeBinDirs: params.trustedSafeBinDirs, + skillBins: params.skillBins, + autoAllowSkills: params.autoAllowSkills, + }); + if (!result.satisfied) { + if (!hasChains) { + return { + allowlistSatisfied: false, + allowlistMatches: result.matches, + segmentSatisfiedBy: result.segmentSatisfiedBy, + }; } - allowlistMatches.push(...result.matches); - segmentSatisfiedBy.push(...result.segmentSatisfiedBy); + return { allowlistSatisfied: false, allowlistMatches: [], segmentSatisfiedBy: [] }; } - return { allowlistSatisfied: true, allowlistMatches, segmentSatisfiedBy }; + allowlistMatches.push(...result.matches); + segmentSatisfiedBy.push(...result.segmentSatisfiedBy); } - - // No chains, evaluate all segments together - const result = evaluateSegments(params.analysis.segments, { - allowlist: params.allowlist, - safeBins: params.safeBins, - safeBinProfiles: params.safeBinProfiles, - cwd: params.cwd, - platform: params.platform, - trustedSafeBinDirs: params.trustedSafeBinDirs, - skillBins: params.skillBins, - autoAllowSkills: params.autoAllowSkills, - }); - return { - allowlistSatisfied: result.satisfied, - allowlistMatches: result.matches, - segmentSatisfiedBy: result.segmentSatisfiedBy, - }; + return { allowlistSatisfied: true, allowlistMatches, segmentSatisfiedBy }; } export type ExecAllowlistAnalysis = { diff --git a/src/infra/exec-safe-bin-policy-profiles.ts b/src/infra/exec-safe-bin-policy-profiles.ts new file mode 100644 index 000000000000..b450325d2fec --- /dev/null +++ b/src/infra/exec-safe-bin-policy-profiles.ts @@ -0,0 +1,315 @@ +export type SafeBinProfile = { + minPositional?: number; + maxPositional?: number; + allowedValueFlags?: ReadonlySet; + deniedFlags?: ReadonlySet; + // Precomputed long-option metadata for GNU abbreviation resolution. + knownLongFlags?: readonly string[]; + knownLongFlagsSet?: ReadonlySet; + longFlagPrefixMap?: ReadonlyMap; +}; + +export type SafeBinProfileFixture = { + minPositional?: number; + maxPositional?: number; + allowedValueFlags?: readonly string[]; + deniedFlags?: readonly string[]; +}; + +export type SafeBinProfileFixtures = Readonly>; + +const NO_FLAGS: ReadonlySet = new Set(); + +const toFlagSet = (flags?: readonly string[]): ReadonlySet => { + if (!flags || flags.length === 0) { + return NO_FLAGS; + } + return new Set(flags); +}; + +export function collectKnownLongFlags( + allowedValueFlags: ReadonlySet, + deniedFlags: ReadonlySet, +): string[] { + const known = new Set(); + for (const flag of allowedValueFlags) { + if (flag.startsWith("--")) { + known.add(flag); + } + } + for (const flag of deniedFlags) { + if (flag.startsWith("--")) { + known.add(flag); + } + } + return Array.from(known); +} + +export function buildLongFlagPrefixMap( + knownLongFlags: readonly string[], +): ReadonlyMap { + const prefixMap = new Map(); + for (const flag of knownLongFlags) { + if (!flag.startsWith("--") || flag.length <= 2) { + continue; + } + for (let length = 3; length <= flag.length; length += 1) { + const prefix = flag.slice(0, length); + const existing = prefixMap.get(prefix); + if (existing === undefined) { + prefixMap.set(prefix, flag); + continue; + } + if (existing !== flag) { + prefixMap.set(prefix, null); + } + } + } + return prefixMap; +} + +function compileSafeBinProfile(fixture: SafeBinProfileFixture): SafeBinProfile { + const allowedValueFlags = toFlagSet(fixture.allowedValueFlags); + const deniedFlags = toFlagSet(fixture.deniedFlags); + const knownLongFlags = collectKnownLongFlags(allowedValueFlags, deniedFlags); + return { + minPositional: fixture.minPositional, + maxPositional: fixture.maxPositional, + allowedValueFlags, + deniedFlags, + knownLongFlags, + knownLongFlagsSet: new Set(knownLongFlags), + longFlagPrefixMap: buildLongFlagPrefixMap(knownLongFlags), + }; +} + +function compileSafeBinProfiles( + fixtures: Record, +): Record { + return Object.fromEntries( + Object.entries(fixtures).map(([name, fixture]) => [name, compileSafeBinProfile(fixture)]), + ) as Record; +} + +export const SAFE_BIN_PROFILE_FIXTURES: Record = { + jq: { + maxPositional: 1, + allowedValueFlags: ["--arg", "--argjson", "--argstr"], + deniedFlags: [ + "--argfile", + "--rawfile", + "--slurpfile", + "--from-file", + "--library-path", + "-L", + "-f", + ], + }, + grep: { + // Keep grep stdin-only: pattern must come from -e/--regexp. + // Allowing one positional is ambiguous because -e consumes the pattern and + // frees the positional slot for a filename. + maxPositional: 0, + allowedValueFlags: [ + "--regexp", + "--max-count", + "--after-context", + "--before-context", + "--context", + "--devices", + "--binary-files", + "--exclude", + "--include", + "--label", + "-e", + "-m", + "-A", + "-B", + "-C", + "-D", + ], + deniedFlags: [ + "--file", + "--exclude-from", + "--dereference-recursive", + "--directories", + "--recursive", + "-f", + "-d", + "-r", + "-R", + ], + }, + cut: { + maxPositional: 0, + allowedValueFlags: [ + "--bytes", + "--characters", + "--fields", + "--delimiter", + "--output-delimiter", + "-b", + "-c", + "-f", + "-d", + ], + }, + sort: { + maxPositional: 0, + allowedValueFlags: [ + "--key", + "--field-separator", + "--buffer-size", + "--parallel", + "--batch-size", + "-k", + "-t", + "-S", + ], + // --compress-program can invoke an external executable and breaks stdin-only guarantees. + // --random-source/--temporary-directory/-T are filesystem-dependent and not stdin-only. + deniedFlags: [ + "--compress-program", + "--files0-from", + "--output", + "--random-source", + "--temporary-directory", + "-T", + "-o", + ], + }, + uniq: { + maxPositional: 0, + allowedValueFlags: [ + "--skip-fields", + "--skip-chars", + "--check-chars", + "--group", + "-f", + "-s", + "-w", + ], + }, + head: { + maxPositional: 0, + allowedValueFlags: ["--lines", "--bytes", "-n", "-c"], + }, + tail: { + maxPositional: 0, + allowedValueFlags: [ + "--lines", + "--bytes", + "--sleep-interval", + "--max-unchanged-stats", + "--pid", + "-n", + "-c", + ], + }, + tr: { + minPositional: 1, + maxPositional: 2, + }, + wc: { + maxPositional: 0, + deniedFlags: ["--files0-from"], + }, +}; + +export const SAFE_BIN_PROFILES: Record = + compileSafeBinProfiles(SAFE_BIN_PROFILE_FIXTURES); + +function normalizeSafeBinProfileName(raw: string): string | null { + const name = raw.trim().toLowerCase(); + return name.length > 0 ? name : null; +} + +function normalizeFixtureLimit(raw: number | undefined): number | undefined { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return undefined; + } + const next = Math.trunc(raw); + return next >= 0 ? next : undefined; +} + +function normalizeFixtureFlags( + flags: readonly string[] | undefined, +): readonly string[] | undefined { + if (!Array.isArray(flags) || flags.length === 0) { + return undefined; + } + const normalized = Array.from( + new Set(flags.map((flag) => flag.trim()).filter((flag) => flag.length > 0)), + ).toSorted((a, b) => a.localeCompare(b)); + return normalized.length > 0 ? normalized : undefined; +} + +function normalizeSafeBinProfileFixture(fixture: SafeBinProfileFixture): SafeBinProfileFixture { + const minPositional = normalizeFixtureLimit(fixture.minPositional); + const maxPositionalRaw = normalizeFixtureLimit(fixture.maxPositional); + const maxPositional = + minPositional !== undefined && + maxPositionalRaw !== undefined && + maxPositionalRaw < minPositional + ? minPositional + : maxPositionalRaw; + return { + minPositional, + maxPositional, + allowedValueFlags: normalizeFixtureFlags(fixture.allowedValueFlags), + deniedFlags: normalizeFixtureFlags(fixture.deniedFlags), + }; +} + +export function normalizeSafeBinProfileFixtures( + fixtures?: SafeBinProfileFixtures | null, +): Record { + const normalized: Record = {}; + if (!fixtures) { + return normalized; + } + for (const [rawName, fixture] of Object.entries(fixtures)) { + const name = normalizeSafeBinProfileName(rawName); + if (!name) { + continue; + } + normalized[name] = normalizeSafeBinProfileFixture(fixture); + } + return normalized; +} + +export function resolveSafeBinProfiles( + fixtures?: SafeBinProfileFixtures | null, +): Record { + const normalizedFixtures = normalizeSafeBinProfileFixtures(fixtures); + if (Object.keys(normalizedFixtures).length === 0) { + return SAFE_BIN_PROFILES; + } + return { + ...SAFE_BIN_PROFILES, + ...compileSafeBinProfiles(normalizedFixtures), + }; +} + +export function resolveSafeBinDeniedFlags( + fixtures: Readonly> = SAFE_BIN_PROFILE_FIXTURES, +): Record { + const out: Record = {}; + for (const [name, fixture] of Object.entries(fixtures)) { + const denied = Array.from(new Set(fixture.deniedFlags ?? [])).toSorted(); + if (denied.length > 0) { + out[name] = denied; + } + } + return out; +} + +export function renderSafeBinDeniedFlagsDocBullets( + fixtures: Readonly> = SAFE_BIN_PROFILE_FIXTURES, +): string { + const deniedByBin = resolveSafeBinDeniedFlags(fixtures); + const bins = Object.keys(deniedByBin).toSorted(); + return bins + .map((bin) => `- \`${bin}\`: ${deniedByBin[bin].map((flag) => `\`${flag}\``).join(", ")}`) + .join("\n"); +} diff --git a/src/infra/exec-safe-bin-policy-validator.ts b/src/infra/exec-safe-bin-policy-validator.ts new file mode 100644 index 000000000000..831602852424 --- /dev/null +++ b/src/infra/exec-safe-bin-policy-validator.ts @@ -0,0 +1,206 @@ +import { parseExecArgvToken } from "./exec-approvals-analysis.js"; +import { + buildLongFlagPrefixMap, + collectKnownLongFlags, + type SafeBinProfile, +} from "./exec-safe-bin-policy-profiles.js"; + +function isPathLikeToken(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + if (trimmed === "-") { + return false; + } + if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("~")) { + return true; + } + if (trimmed.startsWith("/")) { + return true; + } + return /^[A-Za-z]:[\\/]/.test(trimmed); +} + +function hasGlobToken(value: string): boolean { + // Safe bins are stdin-only; globbing is both surprising and a historical bypass vector. + // Note: we still harden execution-time expansion separately. + return /[*?[\]]/.test(value); +} + +const NO_FLAGS: ReadonlySet = new Set(); + +function isSafeLiteralToken(value: string): boolean { + if (!value || value === "-") { + return true; + } + return !hasGlobToken(value) && !isPathLikeToken(value); +} + +function isInvalidValueToken(value: string | undefined): boolean { + return !value || !isSafeLiteralToken(value); +} + +function resolveCanonicalLongFlag(params: { + flag: string; + knownLongFlagsSet: ReadonlySet; + longFlagPrefixMap: ReadonlyMap; +}): string | null { + if (!params.flag.startsWith("--") || params.flag.length <= 2) { + return null; + } + if (params.knownLongFlagsSet.has(params.flag)) { + return params.flag; + } + return params.longFlagPrefixMap.get(params.flag) ?? null; +} + +function consumeLongOptionToken(params: { + args: string[]; + index: number; + flag: string; + inlineValue: string | undefined; + allowedValueFlags: ReadonlySet; + deniedFlags: ReadonlySet; + knownLongFlagsSet: ReadonlySet; + longFlagPrefixMap: ReadonlyMap; +}): number { + const canonicalFlag = resolveCanonicalLongFlag({ + flag: params.flag, + knownLongFlagsSet: params.knownLongFlagsSet, + longFlagPrefixMap: params.longFlagPrefixMap, + }); + if (!canonicalFlag) { + return -1; + } + if (params.deniedFlags.has(canonicalFlag)) { + return -1; + } + const expectsValue = params.allowedValueFlags.has(canonicalFlag); + if (params.inlineValue !== undefined) { + if (!expectsValue) { + return -1; + } + return isSafeLiteralToken(params.inlineValue) ? params.index + 1 : -1; + } + if (!expectsValue) { + return params.index + 1; + } + return isInvalidValueToken(params.args[params.index + 1]) ? -1 : params.index + 2; +} + +function consumeShortOptionClusterToken(params: { + args: string[]; + index: number; + cluster: string; + flags: string[]; + allowedValueFlags: ReadonlySet; + deniedFlags: ReadonlySet; +}): number { + for (let j = 0; j < params.flags.length; j += 1) { + const flag = params.flags[j]; + if (params.deniedFlags.has(flag)) { + return -1; + } + if (!params.allowedValueFlags.has(flag)) { + continue; + } + const inlineValue = params.cluster.slice(j + 1); + if (inlineValue) { + return isSafeLiteralToken(inlineValue) ? params.index + 1 : -1; + } + return isInvalidValueToken(params.args[params.index + 1]) ? -1 : params.index + 2; + } + return -1; +} + +function consumePositionalToken(token: string, positional: string[]): boolean { + if (!isSafeLiteralToken(token)) { + return false; + } + positional.push(token); + return true; +} + +function validatePositionalCount(positional: string[], profile: SafeBinProfile): boolean { + const minPositional = profile.minPositional ?? 0; + if (positional.length < minPositional) { + return false; + } + return typeof profile.maxPositional !== "number" || positional.length <= profile.maxPositional; +} + +export function validateSafeBinArgv(args: string[], profile: SafeBinProfile): boolean { + const allowedValueFlags = profile.allowedValueFlags ?? NO_FLAGS; + const deniedFlags = profile.deniedFlags ?? NO_FLAGS; + const knownLongFlags = + profile.knownLongFlags ?? collectKnownLongFlags(allowedValueFlags, deniedFlags); + const knownLongFlagsSet = profile.knownLongFlagsSet ?? new Set(knownLongFlags); + const longFlagPrefixMap = profile.longFlagPrefixMap ?? buildLongFlagPrefixMap(knownLongFlags); + + const positional: string[] = []; + let i = 0; + while (i < args.length) { + const rawToken = args[i] ?? ""; + const token = parseExecArgvToken(rawToken); + + if (token.kind === "empty" || token.kind === "stdin") { + i += 1; + continue; + } + + if (token.kind === "terminator") { + for (let j = i + 1; j < args.length; j += 1) { + const rest = args[j]; + if (!rest || rest === "-") { + continue; + } + if (!consumePositionalToken(rest, positional)) { + return false; + } + } + break; + } + + if (token.kind === "positional") { + if (!consumePositionalToken(token.raw, positional)) { + return false; + } + i += 1; + continue; + } + + if (token.style === "long") { + const nextIndex = consumeLongOptionToken({ + args, + index: i, + flag: token.flag, + inlineValue: token.inlineValue, + allowedValueFlags, + deniedFlags, + knownLongFlagsSet, + longFlagPrefixMap, + }); + if (nextIndex < 0) { + return false; + } + i = nextIndex; + continue; + } + + const nextIndex = consumeShortOptionClusterToken({ + args, + index: i, + cluster: token.cluster, + flags: token.flags, + allowedValueFlags, + deniedFlags, + }); + if (nextIndex < 0) { + return false; + } + i = nextIndex; + } + + return validatePositionalCount(positional, profile); +} diff --git a/src/infra/exec-safe-bin-policy.ts b/src/infra/exec-safe-bin-policy.ts index d726bb55a105..cd8598098289 100644 --- a/src/infra/exec-safe-bin-policy.ts +++ b/src/infra/exec-safe-bin-policy.ts @@ -1,472 +1,15 @@ -import { parseExecArgvToken } from "./exec-approvals-analysis.js"; - -function isPathLikeToken(value: string): boolean { - const trimmed = value.trim(); - if (!trimmed) { - return false; - } - if (trimmed === "-") { - return false; - } - if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("~")) { - return true; - } - if (trimmed.startsWith("/")) { - return true; - } - return /^[A-Za-z]:[\\/]/.test(trimmed); -} - -function hasGlobToken(value: string): boolean { - // Safe bins are stdin-only; globbing is both surprising and a historical bypass vector. - // Note: we still harden execution-time expansion separately. - return /[*?[\]]/.test(value); -} - -export type SafeBinProfile = { - minPositional?: number; - maxPositional?: number; - allowedValueFlags?: ReadonlySet; - deniedFlags?: ReadonlySet; -}; - -export type SafeBinProfileFixture = { - minPositional?: number; - maxPositional?: number; - allowedValueFlags?: readonly string[]; - deniedFlags?: readonly string[]; -}; - -export type SafeBinProfileFixtures = Readonly>; - -const NO_FLAGS: ReadonlySet = new Set(); - -const toFlagSet = (flags?: readonly string[]): ReadonlySet => { - if (!flags || flags.length === 0) { - return NO_FLAGS; - } - return new Set(flags); -}; - -function compileSafeBinProfile(fixture: SafeBinProfileFixture): SafeBinProfile { - return { - minPositional: fixture.minPositional, - maxPositional: fixture.maxPositional, - allowedValueFlags: toFlagSet(fixture.allowedValueFlags), - deniedFlags: toFlagSet(fixture.deniedFlags), - }; -} - -function compileSafeBinProfiles( - fixtures: Record, -): Record { - return Object.fromEntries( - Object.entries(fixtures).map(([name, fixture]) => [name, compileSafeBinProfile(fixture)]), - ) as Record; -} - -export const SAFE_BIN_PROFILE_FIXTURES: Record = { - jq: { - maxPositional: 1, - allowedValueFlags: ["--arg", "--argjson", "--argstr"], - deniedFlags: [ - "--argfile", - "--rawfile", - "--slurpfile", - "--from-file", - "--library-path", - "-L", - "-f", - ], - }, - grep: { - // Keep grep stdin-only: pattern must come from -e/--regexp. - // Allowing one positional is ambiguous because -e consumes the pattern and - // frees the positional slot for a filename. - maxPositional: 0, - allowedValueFlags: [ - "--regexp", - "--max-count", - "--after-context", - "--before-context", - "--context", - "--devices", - "--binary-files", - "--exclude", - "--include", - "--label", - "-e", - "-m", - "-A", - "-B", - "-C", - "-D", - ], - deniedFlags: [ - "--file", - "--exclude-from", - "--dereference-recursive", - "--directories", - "--recursive", - "-f", - "-d", - "-r", - "-R", - ], - }, - cut: { - maxPositional: 0, - allowedValueFlags: [ - "--bytes", - "--characters", - "--fields", - "--delimiter", - "--output-delimiter", - "-b", - "-c", - "-f", - "-d", - ], - }, - sort: { - maxPositional: 0, - allowedValueFlags: [ - "--key", - "--field-separator", - "--buffer-size", - "--parallel", - "--batch-size", - "-k", - "-t", - "-S", - ], - // --compress-program can invoke an external executable and breaks stdin-only guarantees. - // --random-source/--temporary-directory/-T are filesystem-dependent and not stdin-only. - deniedFlags: [ - "--compress-program", - "--files0-from", - "--output", - "--random-source", - "--temporary-directory", - "-T", - "-o", - ], - }, - uniq: { - maxPositional: 0, - allowedValueFlags: [ - "--skip-fields", - "--skip-chars", - "--check-chars", - "--group", - "-f", - "-s", - "-w", - ], - }, - head: { - maxPositional: 0, - allowedValueFlags: ["--lines", "--bytes", "-n", "-c"], - }, - tail: { - maxPositional: 0, - allowedValueFlags: [ - "--lines", - "--bytes", - "--sleep-interval", - "--max-unchanged-stats", - "--pid", - "-n", - "-c", - ], - }, - tr: { - minPositional: 1, - maxPositional: 2, - }, - wc: { - maxPositional: 0, - deniedFlags: ["--files0-from"], - }, -}; - -export const SAFE_BIN_PROFILES: Record = - compileSafeBinProfiles(SAFE_BIN_PROFILE_FIXTURES); - -function normalizeSafeBinProfileName(raw: string): string | null { - const name = raw.trim().toLowerCase(); - return name.length > 0 ? name : null; -} - -function normalizeFixtureLimit(raw: number | undefined): number | undefined { - if (typeof raw !== "number" || !Number.isFinite(raw)) { - return undefined; - } - const next = Math.trunc(raw); - return next >= 0 ? next : undefined; -} - -function normalizeFixtureFlags( - flags: readonly string[] | undefined, -): readonly string[] | undefined { - if (!Array.isArray(flags) || flags.length === 0) { - return undefined; - } - const normalized = Array.from( - new Set(flags.map((flag) => flag.trim()).filter((flag) => flag.length > 0)), - ).toSorted((a, b) => a.localeCompare(b)); - return normalized.length > 0 ? normalized : undefined; -} - -function normalizeSafeBinProfileFixture(fixture: SafeBinProfileFixture): SafeBinProfileFixture { - const minPositional = normalizeFixtureLimit(fixture.minPositional); - const maxPositionalRaw = normalizeFixtureLimit(fixture.maxPositional); - const maxPositional = - minPositional !== undefined && - maxPositionalRaw !== undefined && - maxPositionalRaw < minPositional - ? minPositional - : maxPositionalRaw; - return { - minPositional, - maxPositional, - allowedValueFlags: normalizeFixtureFlags(fixture.allowedValueFlags), - deniedFlags: normalizeFixtureFlags(fixture.deniedFlags), - }; -} - -export function normalizeSafeBinProfileFixtures( - fixtures?: SafeBinProfileFixtures | null, -): Record { - const normalized: Record = {}; - if (!fixtures) { - return normalized; - } - for (const [rawName, fixture] of Object.entries(fixtures)) { - const name = normalizeSafeBinProfileName(rawName); - if (!name) { - continue; - } - normalized[name] = normalizeSafeBinProfileFixture(fixture); - } - return normalized; -} - -export function resolveSafeBinProfiles( - fixtures?: SafeBinProfileFixtures | null, -): Record { - const normalizedFixtures = normalizeSafeBinProfileFixtures(fixtures); - if (Object.keys(normalizedFixtures).length === 0) { - return SAFE_BIN_PROFILES; - } - return { - ...SAFE_BIN_PROFILES, - ...compileSafeBinProfiles(normalizedFixtures), - }; -} - -export function resolveSafeBinDeniedFlags( - fixtures: Readonly> = SAFE_BIN_PROFILE_FIXTURES, -): Record { - const out: Record = {}; - for (const [name, fixture] of Object.entries(fixtures)) { - const denied = Array.from(new Set(fixture.deniedFlags ?? [])).toSorted(); - if (denied.length > 0) { - out[name] = denied; - } - } - return out; -} - -export function renderSafeBinDeniedFlagsDocBullets( - fixtures: Readonly> = SAFE_BIN_PROFILE_FIXTURES, -): string { - const deniedByBin = resolveSafeBinDeniedFlags(fixtures); - const bins = Object.keys(deniedByBin).toSorted(); - return bins - .map((bin) => `- \`${bin}\`: ${deniedByBin[bin].map((flag) => `\`${flag}\``).join(", ")}`) - .join("\n"); -} - -function isSafeLiteralToken(value: string): boolean { - if (!value || value === "-") { - return true; - } - return !hasGlobToken(value) && !isPathLikeToken(value); -} - -function isInvalidValueToken(value: string | undefined): boolean { - return !value || !isSafeLiteralToken(value); -} - -function collectKnownLongFlags( - allowedValueFlags: ReadonlySet, - deniedFlags: ReadonlySet, -): string[] { - const known = new Set(); - for (const flag of allowedValueFlags) { - if (flag.startsWith("--")) { - known.add(flag); - } - } - for (const flag of deniedFlags) { - if (flag.startsWith("--")) { - known.add(flag); - } - } - return Array.from(known); -} - -function resolveCanonicalLongFlag(flag: string, knownLongFlags: string[]): string | null { - if (!flag.startsWith("--") || flag.length <= 2) { - return null; - } - if (knownLongFlags.includes(flag)) { - return flag; - } - const matches = knownLongFlags.filter((candidate) => candidate.startsWith(flag)); - if (matches.length !== 1) { - return null; - } - return matches[0] ?? null; -} - -function consumeLongOptionToken( - args: string[], - index: number, - flag: string, - inlineValue: string | undefined, - allowedValueFlags: ReadonlySet, - deniedFlags: ReadonlySet, -): number { - const knownLongFlags = collectKnownLongFlags(allowedValueFlags, deniedFlags); - const canonicalFlag = resolveCanonicalLongFlag(flag, knownLongFlags); - if (!canonicalFlag) { - return -1; - } - if (deniedFlags.has(canonicalFlag)) { - return -1; - } - const expectsValue = allowedValueFlags.has(canonicalFlag); - if (inlineValue !== undefined) { - if (!expectsValue) { - return -1; - } - return isSafeLiteralToken(inlineValue) ? index + 1 : -1; - } - if (!expectsValue) { - return index + 1; - } - return isInvalidValueToken(args[index + 1]) ? -1 : index + 2; -} - -function consumeShortOptionClusterToken( - args: string[], - index: number, - _raw: string, - cluster: string, - flags: string[], - allowedValueFlags: ReadonlySet, - deniedFlags: ReadonlySet, -): number { - for (let j = 0; j < flags.length; j += 1) { - const flag = flags[j]; - if (deniedFlags.has(flag)) { - return -1; - } - if (!allowedValueFlags.has(flag)) { - continue; - } - const inlineValue = cluster.slice(j + 1); - if (inlineValue) { - return isSafeLiteralToken(inlineValue) ? index + 1 : -1; - } - return isInvalidValueToken(args[index + 1]) ? -1 : index + 2; - } - return -1; -} - -function consumePositionalToken(token: string, positional: string[]): boolean { - if (!isSafeLiteralToken(token)) { - return false; - } - positional.push(token); - return true; -} - -function validatePositionalCount(positional: string[], profile: SafeBinProfile): boolean { - const minPositional = profile.minPositional ?? 0; - if (positional.length < minPositional) { - return false; - } - return typeof profile.maxPositional !== "number" || positional.length <= profile.maxPositional; -} - -export function validateSafeBinArgv(args: string[], profile: SafeBinProfile): boolean { - const allowedValueFlags = profile.allowedValueFlags ?? NO_FLAGS; - const deniedFlags = profile.deniedFlags ?? NO_FLAGS; - const positional: string[] = []; - let i = 0; - while (i < args.length) { - const rawToken = args[i] ?? ""; - const token = parseExecArgvToken(rawToken); - - if (token.kind === "empty" || token.kind === "stdin") { - i += 1; - continue; - } - - if (token.kind === "terminator") { - for (let j = i + 1; j < args.length; j += 1) { - const rest = args[j]; - if (!rest || rest === "-") { - continue; - } - if (!consumePositionalToken(rest, positional)) { - return false; - } - } - break; - } - - if (token.kind === "positional") { - if (!consumePositionalToken(token.raw, positional)) { - return false; - } - i += 1; - continue; - } - - if (token.style === "long") { - const nextIndex = consumeLongOptionToken( - args, - i, - token.flag, - token.inlineValue, - allowedValueFlags, - deniedFlags, - ); - if (nextIndex < 0) { - return false; - } - i = nextIndex; - continue; - } - - const nextIndex = consumeShortOptionClusterToken( - args, - i, - token.raw, - token.cluster, - token.flags, - allowedValueFlags, - deniedFlags, - ); - if (nextIndex < 0) { - return false; - } - i = nextIndex; - } - - return validatePositionalCount(positional, profile); -} +export { + SAFE_BIN_PROFILE_FIXTURES, + SAFE_BIN_PROFILES, + buildLongFlagPrefixMap, + collectKnownLongFlags, + normalizeSafeBinProfileFixtures, + renderSafeBinDeniedFlagsDocBullets, + resolveSafeBinDeniedFlags, + resolveSafeBinProfiles, + type SafeBinProfile, + type SafeBinProfileFixture, + type SafeBinProfileFixtures, +} from "./exec-safe-bin-policy-profiles.js"; + +export { validateSafeBinArgv } from "./exec-safe-bin-policy-validator.js"; From 4a3f8438e527ac371a67fe7ac68a287f0dbe6063 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:05:36 +0000 Subject: [PATCH 008/408] fix(gateway): bind node exec approvals to nodeId --- CHANGELOG.md | 1 + .../bash-tools.exec-approval-request.test.ts | 3 + .../bash-tools.exec-approval-request.ts | 4 + src/agents/bash-tools.exec-host-node.ts | 1 + src/agents/openclaw-tools.camera.test.ts | 1 + src/agents/tools/nodes-tool.ts | 1 + src/cli/nodes-cli/register.invoke.ts | 1 + src/gateway/exec-approval-manager.ts | 1 + src/gateway/node-invoke-sanitize.ts | 2 + .../node-invoke-system-run-approval.test.ts | 49 +++++++++ .../node-invoke-system-run-approval.ts | 25 +++++ src/gateway/protocol/schema/exec-approvals.ts | 1 + src/gateway/server-methods/exec-approval.ts | 14 ++- src/gateway/server-methods/nodes.ts | 1 + .../server-methods/server-methods.test.ts | 24 ++++ ...server.node-invoke-approval-bypass.test.ts | 103 +++++++++++++++++- src/infra/exec-approval-forwarder.ts | 3 + src/infra/exec-approvals.ts | 1 + 18 files changed, 231 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a712c74de66..03b2e4ecaae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. diff --git a/src/agents/bash-tools.exec-approval-request.test.ts b/src/agents/bash-tools.exec-approval-request.test.ts index 35f5e0408695..a0722002c644 100644 --- a/src/agents/bash-tools.exec-approval-request.test.ts +++ b/src/agents/bash-tools.exec-approval-request.test.ts @@ -44,6 +44,7 @@ describe("requestExecApprovalDecision", () => { id: "approval-id", command: "echo hi", cwd: "/tmp", + nodeId: undefined, host: "gateway", security: "allowlist", ask: "always", @@ -62,6 +63,7 @@ describe("requestExecApprovalDecision", () => { id: "approval-id", command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", security: "allowlist", ask: "on-miss", @@ -74,6 +76,7 @@ describe("requestExecApprovalDecision", () => { id: "approval-id-2", command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", security: "allowlist", ask: "on-miss", diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts index 2b08495a400e..7f0b59736d56 100644 --- a/src/agents/bash-tools.exec-approval-request.ts +++ b/src/agents/bash-tools.exec-approval-request.ts @@ -9,6 +9,7 @@ export type RequestExecApprovalDecisionParams = { id: string; command: string; cwd: string; + nodeId?: string; host: "gateway" | "node"; security: ExecSecurity; ask: ExecAsk; @@ -27,6 +28,7 @@ export async function requestExecApprovalDecision( id: params.id, command: params.command, cwd: params.cwd, + nodeId: params.nodeId, host: params.host, security: params.security, ask: params.ask, @@ -48,6 +50,7 @@ export async function requestExecApprovalDecisionForHost(params: { command: string; workdir: string; host: "gateway" | "node"; + nodeId?: string; security: ExecSecurity; ask: ExecAsk; agentId?: string; @@ -58,6 +61,7 @@ export async function requestExecApprovalDecisionForHost(params: { id: params.approvalId, command: params.command, cwd: params.workdir, + nodeId: params.nodeId, host: params.host, security: params.security, ask: params.ask, diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index 9a663c2a088c..fc6893b93bf3 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -193,6 +193,7 @@ export async function executeNodeHostCommand( command: params.command, workdir: params.workdir, host: "node", + nodeId, security: hostSecurity, ask: hostAsk, agentId: params.agentId, diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts index fb927d338880..3082c849609f 100644 --- a/src/agents/openclaw-tools.camera.test.ts +++ b/src/agents/openclaw-tools.camera.test.ts @@ -165,6 +165,7 @@ describe("nodes run", () => { expect(params).toMatchObject({ id: expect.any(String), command: "echo hi", + nodeId: NODE_ID, host: "node", timeoutMs: 120_000, }); diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 3188d7dc1b80..c17ff9f9c488 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -482,6 +482,7 @@ export function createNodesTool(options?: { id: approvalId, command: cmdText, cwd, + nodeId, host: "node", agentId, sessionKey, diff --git a/src/cli/nodes-cli/register.invoke.ts b/src/cli/nodes-cli/register.invoke.ts index 2a7ec004f848..a53cc783041e 100644 --- a/src/cli/nodes-cli/register.invoke.ts +++ b/src/cli/nodes-cli/register.invoke.ts @@ -253,6 +253,7 @@ export function registerNodesInvokeCommands(nodes: Command) { id: approvalId, command: rawCommand ?? argv.join(" "), cwd: opts.cwd, + nodeId, host: "node", security: hostSecurity, ask: hostAsk, diff --git a/src/gateway/exec-approval-manager.ts b/src/gateway/exec-approval-manager.ts index 8a2828da9702..a065be1916a8 100644 --- a/src/gateway/exec-approval-manager.ts +++ b/src/gateway/exec-approval-manager.ts @@ -7,6 +7,7 @@ const RESOLVED_ENTRY_GRACE_MS = 15_000; export type ExecApprovalRequestPayload = { command: string; cwd?: string | null; + nodeId?: string | null; host?: string | null; security?: string | null; ask?: string | null; diff --git a/src/gateway/node-invoke-sanitize.ts b/src/gateway/node-invoke-sanitize.ts index c794405ddea5..651399dce08b 100644 --- a/src/gateway/node-invoke-sanitize.ts +++ b/src/gateway/node-invoke-sanitize.ts @@ -3,6 +3,7 @@ import { sanitizeSystemRunParamsForForwarding } from "./node-invoke-system-run-a import type { GatewayClient } from "./server-methods/types.js"; export function sanitizeNodeInvokeParamsForForwarding(opts: { + nodeId: string; command: string; rawParams: unknown; client: GatewayClient | null; @@ -12,6 +13,7 @@ export function sanitizeNodeInvokeParamsForForwarding(opts: { | { ok: false; message: string; details?: Record } { if (opts.command === "system.run") { return sanitizeSystemRunParamsForForwarding({ + nodeId: opts.nodeId, rawParams: opts.rawParams, client: opts.client, execApprovalManager: opts.execApprovalManager, diff --git a/src/gateway/node-invoke-system-run-approval.test.ts b/src/gateway/node-invoke-system-run-approval.test.ts index a5a7c3d9f0df..ddae856048b9 100644 --- a/src/gateway/node-invoke-system-run-approval.test.ts +++ b/src/gateway/node-invoke-system-run-approval.test.ts @@ -18,6 +18,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { id: "approval-1", request: { host: "node", + nodeId: "node-1", command, cwd: null, agentId: null, @@ -61,6 +62,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { approved: true, approvalDecision: "allow-once", }, + nodeId: "node-1", client, execApprovalManager: manager(makeRecord("echo")), nowMs: now, @@ -82,6 +84,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { approved: true, approvalDecision: "allow-once", }, + nodeId: "node-1", client, execApprovalManager: manager(makeRecord("echo SAFE&&whoami")), nowMs: now, @@ -97,6 +100,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { approved: true, approvalDecision: "allow-once", }, + nodeId: "node-1", client, execApprovalManager: manager(makeRecord("echo SAFE")), nowMs: now, @@ -117,6 +121,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { approved: true, approvalDecision: "allow-once", }, + nodeId: "node-1", client, execApprovalManager: manager( makeRecord('/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc "echo SAFE"'), @@ -125,4 +130,48 @@ describe("sanitizeSystemRunParamsForForwarding", () => { }); expectAllowOnceForwardingResult(result); }); + + test("rejects approval ids that do not bind a nodeId", () => { + const record = makeRecord("echo SAFE"); + record.request.nodeId = null; + const result = sanitizeSystemRunParamsForForwarding({ + rawParams: { + command: ["echo", "SAFE"], + runId: "approval-1", + approved: true, + approvalDecision: "allow-once", + }, + nodeId: "node-1", + client, + execApprovalManager: manager(record), + nowMs: now, + }); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("unreachable"); + } + expect(result.message).toContain("missing node binding"); + expect(result.details?.code).toBe("APPROVAL_NODE_BINDING_MISSING"); + }); + + test("rejects approval ids replayed against a different nodeId", () => { + const result = sanitizeSystemRunParamsForForwarding({ + rawParams: { + command: ["echo", "SAFE"], + runId: "approval-1", + approved: true, + approvalDecision: "allow-once", + }, + nodeId: "node-2", + client, + execApprovalManager: manager(makeRecord("echo SAFE")), + nowMs: now, + }); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("unreachable"); + } + expect(result.message).toContain("not valid for this node"); + expect(result.details?.code).toBe("APPROVAL_NODE_MISMATCH"); + }); }); diff --git a/src/gateway/node-invoke-system-run-approval.ts b/src/gateway/node-invoke-system-run-approval.ts index 5684f4221f51..5bf31db8fb52 100644 --- a/src/gateway/node-invoke-system-run-approval.ts +++ b/src/gateway/node-invoke-system-run-approval.ts @@ -114,6 +114,7 @@ function pickSystemRunParams(raw: Record): Record 0 ? p.id.trim() : null; + const host = typeof p.host === "string" ? p.host.trim() : ""; + const nodeId = typeof p.nodeId === "string" ? p.nodeId.trim() : ""; + if (host === "node" && !nodeId) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "nodeId is required for host=node"), + ); + return; + } if (explicitId && manager.getSnapshot(explicitId)) { respond( false, @@ -68,7 +79,8 @@ export function createExecApprovalHandlers( const request = { command: p.command, cwd: p.cwd ?? null, - host: p.host ?? null, + nodeId: host === "node" ? nodeId : null, + host: host || null, security: p.security ?? null, ask: p.ask ?? null, agentId: p.agentId ?? null, diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 4f076abd59c8..f02210331556 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -698,6 +698,7 @@ export const nodeHandlers: GatewayRequestHandlers = { return; } const forwardedParams = sanitizeNodeInvokeParamsForForwarding({ + nodeId, command, rawParams: p.params, client, diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 60349d9c0e4f..b19a6d8c6082 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -248,6 +248,7 @@ describe("exec approval handlers", () => { const defaultExecApprovalRequestParams = { command: "echo ok", cwd: "/tmp", + nodeId: "node-1", host: "node", timeoutMs: 2000, } as const; @@ -323,6 +324,7 @@ describe("exec approval handlers", () => { const params = { command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", }; expect(validateExecApprovalRequestParams(params)).toBe(true); @@ -332,6 +334,7 @@ describe("exec approval handlers", () => { const params = { command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", resolvedPath: "/usr/bin/echo", }; @@ -342,6 +345,7 @@ describe("exec approval handlers", () => { const params = { command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", resolvedPath: undefined, }; @@ -352,6 +356,7 @@ describe("exec approval handlers", () => { const params = { command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", resolvedPath: null, }; @@ -359,6 +364,25 @@ describe("exec approval handlers", () => { }); }); + it("rejects host=node approval requests without nodeId", async () => { + const { handlers, respond, context } = createExecApprovalFixture(); + await requestExecApproval({ + handlers, + respond, + context, + params: { + nodeId: undefined, + }, + }); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: "nodeId is required for host=node", + }), + ); + }); + it("broadcasts request + resolve", async () => { const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); diff --git a/src/gateway/server.node-invoke-approval-bypass.test.ts b/src/gateway/server.node-invoke-approval-bypass.test.ts index 9a78453a199a..7cc84b5b8d8a 100644 --- a/src/gateway/server.node-invoke-approval-bypass.test.ts +++ b/src/gateway/server.node-invoke-approval-bypass.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { WebSocket } from "ws"; import { deriveDeviceIdFromPublicKey, + type DeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload, } from "../infra/device-identity.js"; @@ -23,6 +24,22 @@ installGatewayTestHooks({ scope: "suite" }); const NODE_CONNECT_TIMEOUT_MS = 3_000; const CONNECT_REQ_TIMEOUT_MS = 2_000; +function createDeviceIdentity(): DeviceIdentity { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); + const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem); + const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw); + if (!deviceId) { + throw new Error("failed to create test device identity"); + } + return { + deviceId, + publicKeyPem, + privateKeyPem, + }; +} + async function expectNoForwardedInvoke(hasInvoke: () => boolean): Promise { // Yield a couple of macrotasks so any accidental async forwarding would fire. await new Promise((resolve) => setImmediate(resolve)); @@ -42,11 +59,26 @@ async function getConnectedNodeId(ws: WebSocket): Promise { return nodeId; } -async function requestAllowOnceApproval(ws: WebSocket, command: string): Promise { +async function getConnectedNodeIds(ws: WebSocket): Promise { + const nodes = await rpcReq<{ nodes?: Array<{ nodeId: string; connected?: boolean }> }>( + ws, + "node.list", + {}, + ); + expect(nodes.ok).toBe(true); + return (nodes.payload?.nodes ?? []).filter((n) => n.connected).map((n) => n.nodeId); +} + +async function requestAllowOnceApproval( + ws: WebSocket, + command: string, + nodeId: string, +): Promise { const approvalId = crypto.randomUUID(); const requestP = rpcReq(ws, "exec.approval.request", { id: approvalId, command, + nodeId, cwd: null, host: "node", timeoutMs: 30_000, @@ -161,7 +193,10 @@ describe("node.invoke approval bypass", () => { }); }; - const connectLinuxNode = async (onInvoke: (payload: unknown) => void) => { + const connectLinuxNode = async ( + onInvoke: (payload: unknown) => void, + deviceIdentity?: DeviceIdentity, + ) => { let readyResolve: (() => void) | null = null; const ready = new Promise((resolve) => { readyResolve = resolve; @@ -180,6 +215,7 @@ describe("node.invoke approval bypass", () => { mode: GATEWAY_CLIENT_MODES.NODE, scopes: [], commands: ["system.run"], + deviceIdentity, onHelloOk: () => readyResolve?.(), onEvent: (evt) => { if (evt.event !== "node.invoke.request") { @@ -295,7 +331,7 @@ describe("node.invoke approval bypass", () => { try { const nodeId = await getConnectedNodeId(wsApprover); - const approvalId = await requestAllowOnceApproval(wsApprover, "echo hi"); + const approvalId = await requestAllowOnceApproval(wsApprover, "echo hi", nodeId); // Separate caller connection simulates per-call clients. const invoke = await rpcReq(wsCaller, "node.invoke", { nodeId, @@ -316,7 +352,7 @@ describe("node.invoke approval bypass", () => { expect(lastInvokeParams?.["approvalDecision"]).toBe("allow-once"); expect(lastInvokeParams?.["injected"]).toBeUndefined(); - const replayApprovalId = await requestAllowOnceApproval(wsApprover, "echo hi"); + const replayApprovalId = await requestAllowOnceApproval(wsApprover, "echo hi", nodeId); const invokeCountBeforeReplay = invokeCount; const replay = await rpcReq(wsOtherDevice, "node.invoke", { nodeId, @@ -340,4 +376,63 @@ describe("node.invoke approval bypass", () => { node.stop(); } }); + + test("blocks cross-node replay on same device", async () => { + const invokeCounts = new Map(); + const onInvoke = (payload: unknown) => { + const obj = payload as { nodeId?: unknown }; + const nodeId = typeof obj?.nodeId === "string" ? obj.nodeId : ""; + if (!nodeId) { + return; + } + invokeCounts.set(nodeId, (invokeCounts.get(nodeId) ?? 0) + 1); + }; + const nodeA = await connectLinuxNode(onInvoke, createDeviceIdentity()); + const nodeB = await connectLinuxNode(onInvoke, createDeviceIdentity()); + + const wsApprover = await connectOperator(["operator.write", "operator.approvals"]); + const wsCaller = await connectOperator(["operator.write"]); + + try { + await expect + .poll(async () => (await getConnectedNodeIds(wsApprover)).length, { + timeout: 3_000, + interval: 50, + }) + .toBeGreaterThanOrEqual(2); + const connectedNodeIds = await getConnectedNodeIds(wsApprover); + const approvedNodeId = connectedNodeIds[0] ?? ""; + const replayNodeId = connectedNodeIds.find((id) => id !== approvedNodeId) ?? ""; + expect(approvedNodeId).toBeTruthy(); + expect(replayNodeId).toBeTruthy(); + + const approvalId = await requestAllowOnceApproval(wsApprover, "echo hi", approvedNodeId); + const beforeReplayApprovedNode = invokeCounts.get(approvedNodeId) ?? 0; + const beforeReplayOtherNode = invokeCounts.get(replayNodeId) ?? 0; + const replay = await rpcReq(wsCaller, "node.invoke", { + nodeId: replayNodeId, + command: "system.run", + params: { + command: ["echo", "hi"], + rawCommand: "echo hi", + runId: approvalId, + approved: true, + approvalDecision: "allow-once", + }, + idempotencyKey: crypto.randomUUID(), + }); + expect(replay.ok).toBe(false); + expect(replay.error?.message ?? "").toContain("not valid for this node"); + await expectNoForwardedInvoke( + () => + (invokeCounts.get(approvedNodeId) ?? 0) > beforeReplayApprovedNode || + (invokeCounts.get(replayNodeId) ?? 0) > beforeReplayOtherNode, + ); + } finally { + wsApprover.close(); + wsCaller.close(); + nodeA.stop(); + nodeB.stop(); + } + }); }); diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 46617f07d7dc..7af7489baf21 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -168,6 +168,9 @@ function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) { if (request.request.cwd) { lines.push(`CWD: ${request.request.cwd}`); } + if (request.request.nodeId) { + lines.push(`Node: ${request.request.nodeId}`); + } if (request.request.host) { lines.push(`Host: ${request.request.host}`); } diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 4fd3f63470dc..be4264e22ecd 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -16,6 +16,7 @@ export type ExecApprovalRequest = { request: { command: string; cwd?: string | null; + nodeId?: string | null; host?: string | null; security?: string | null; ask?: string | null; From a67689a7e3ad494b6637c76235a664322d526f9e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:06:34 +0000 Subject: [PATCH 009/408] fix: harden allow-always shell multiplexer wrapper handling --- CHANGELOG.md | 1 + docs/tools/exec-approvals.md | 4 +- src/infra/exec-approvals-allow-always.test.ts | 100 ++++++++++++++++++ src/infra/exec-approvals-allowlist.ts | 25 +++++ .../exec-safe-bin-runtime-policy.test.ts | 2 + src/infra/exec-safe-bin-runtime-policy.ts | 2 + src/infra/exec-wrapper-resolution.ts | 55 ++++++++++ src/infra/system-run-command.test.ts | 5 + 8 files changed, 193 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b2e4ecaae1..ec759b137e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting. - Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings. - Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: recognize `busybox`/`toybox` shell applets in wrapper analysis and allow-always persistence, persist inner executables instead of multiplexer wrapper binaries, and fail closed when multiplexer unwrapping is unsafe to prevent allow-always bypasses. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 0e6d0f528993..f155fbbd7905 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -178,7 +178,9 @@ For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped env overrides are small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`). For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper -paths. If a wrapper cannot be safely unwrapped, no allowlist entry is persisted automatically. +paths. Shell multiplexers (`busybox`, `toybox`) are also unwrapped for shell applets (`sh`, `ash`, +etc.) so inner executables are persisted instead of multiplexer binaries. If a wrapper or +multiplexer cannot be safely unwrapped, no allowlist entry is persisted automatically. Default safe bins: `jq`, `cut`, `uniq`, `head`, `tail`, `tr`, `wc`. diff --git a/src/infra/exec-approvals-allow-always.test.ts b/src/infra/exec-approvals-allow-always.test.ts index ab43ff17ec5a..640ea8706d63 100644 --- a/src/infra/exec-approvals-allow-always.test.ts +++ b/src/infra/exec-approvals-allow-always.test.ts @@ -153,6 +153,60 @@ describe("resolveAllowAlwaysPatterns", () => { expect(patterns).not.toContain("/usr/bin/nice"); }); + it("unwraps busybox/toybox shell applets and persists inner executables", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const busybox = makeExecutable(dir, "busybox"); + makeExecutable(dir, "toybox"); + const whoami = makeExecutable(dir, "whoami"); + const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; + const patterns = resolveAllowAlwaysPatterns({ + segments: [ + { + raw: `${busybox} sh -lc whoami`, + argv: [busybox, "sh", "-lc", "whoami"], + resolution: { + rawExecutable: busybox, + resolvedPath: busybox, + executableName: "busybox", + }, + }, + ], + cwd: dir, + env, + platform: process.platform, + }); + expect(patterns).toEqual([whoami]); + expect(patterns).not.toContain(busybox); + }); + + it("fails closed for unsupported busybox/toybox applets", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const busybox = makeExecutable(dir, "busybox"); + const patterns = resolveAllowAlwaysPatterns({ + segments: [ + { + raw: `${busybox} sed -n 1p`, + argv: [busybox, "sed", "-n", "1p"], + resolution: { + rawExecutable: busybox, + resolvedPath: busybox, + executableName: "busybox", + }, + }, + ], + cwd: dir, + env: makePathEnv(dir), + platform: process.platform, + }); + expect(patterns).toEqual([]); + }); + it("fails closed for unresolved dispatch wrappers", () => { const patterns = resolveAllowAlwaysPatterns({ segments: [ @@ -171,6 +225,52 @@ describe("resolveAllowAlwaysPatterns", () => { expect(patterns).toEqual([]); }); + it("prevents allow-always bypass for busybox shell applets", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const busybox = makeExecutable(dir, "busybox"); + const echo = makeExecutable(dir, "echo"); + makeExecutable(dir, "id"); + const safeBins = resolveSafeBins(undefined); + const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; + + const first = evaluateShellAllowlist({ + command: `${busybox} sh -c 'echo warmup-ok'`, + allowlist: [], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + const persisted = resolveAllowAlwaysPatterns({ + segments: first.segments, + cwd: dir, + env, + platform: process.platform, + }); + expect(persisted).toEqual([echo]); + + const second = evaluateShellAllowlist({ + command: `${busybox} sh -c 'id > marker'`, + allowlist: [{ pattern: echo }], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + expect(second.allowlistSatisfied).toBe(false); + expect( + requiresExecApproval({ + ask: "on-miss", + security: "allowlist", + analysisOk: second.analysisOk, + allowlistSatisfied: second.allowlistSatisfied, + }), + ).toBe(true); + }); + it("prevents allow-always bypass for dispatch-wrapper + shell-wrapper chains", () => { if (process.platform === "win32") { return; diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index bff632d46be0..25d06994977b 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -21,6 +21,7 @@ import { extractShellWrapperInlineCommand, isDispatchWrapperExecutable, isShellWrapperExecutable, + unwrapKnownShellMultiplexerInvocation, unwrapKnownDispatchWrapperInvocation, } from "./exec-wrapper-resolution.js"; @@ -299,6 +300,30 @@ function collectAllowAlwaysPatterns(params: { return; } + const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(params.segment.argv); + if (shellMultiplexerUnwrap.kind === "blocked") { + return; + } + if (shellMultiplexerUnwrap.kind === "unwrapped") { + collectAllowAlwaysPatterns({ + segment: { + raw: shellMultiplexerUnwrap.argv.join(" "), + argv: shellMultiplexerUnwrap.argv, + resolution: resolveCommandResolutionFromArgv( + shellMultiplexerUnwrap.argv, + params.cwd, + params.env, + ), + }, + cwd: params.cwd, + env: params.env, + platform: params.platform, + depth: params.depth + 1, + out: params.out, + }); + return; + } + const candidatePath = resolveAllowlistCandidatePath(params.segment.resolution, params.cwd); if (!candidatePath) { return; diff --git a/src/infra/exec-safe-bin-runtime-policy.test.ts b/src/infra/exec-safe-bin-runtime-policy.test.ts index 29f29864be2d..e9ee32304056 100644 --- a/src/infra/exec-safe-bin-runtime-policy.test.ts +++ b/src/infra/exec-safe-bin-runtime-policy.test.ts @@ -15,6 +15,8 @@ describe("exec safe-bin runtime policy", () => { { bin: "node20", expected: true }, { bin: "ruby3.2", expected: true }, { bin: "bash", expected: true }, + { bin: "busybox", expected: true }, + { bin: "toybox", expected: true }, { bin: "myfilter", expected: false }, { bin: "jq", expected: false }, ]; diff --git a/src/infra/exec-safe-bin-runtime-policy.ts b/src/infra/exec-safe-bin-runtime-policy.ts index a6f71d16f918..9ed56bfe680e 100644 --- a/src/infra/exec-safe-bin-runtime-policy.ts +++ b/src/infra/exec-safe-bin-runtime-policy.ts @@ -17,6 +17,7 @@ export type ExecSafeBinConfigScope = { const INTERPRETER_LIKE_SAFE_BINS = new Set([ "ash", "bash", + "busybox", "bun", "cmd", "cmd.exe", @@ -40,6 +41,7 @@ const INTERPRETER_LIKE_SAFE_BINS = new Set([ "python3", "ruby", "sh", + "toybox", "wscript", "zsh", ]); diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts index 58fc18b0015a..55e05842e365 100644 --- a/src/infra/exec-wrapper-resolution.ts +++ b/src/infra/exec-wrapper-resolution.ts @@ -7,6 +7,7 @@ const WINDOWS_EXE_SUFFIX = ".exe"; const POSIX_SHELL_WRAPPER_NAMES = ["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"] as const; const WINDOWS_CMD_WRAPPER_NAMES = ["cmd"] as const; const POWERSHELL_WRAPPER_NAMES = ["powershell", "pwsh"] as const; +const SHELL_MULTIPLEXER_WRAPPER_NAMES = ["busybox", "toybox"] as const; const DISPATCH_WRAPPER_NAMES = [ "chrt", "doas", @@ -42,6 +43,7 @@ export const DISPATCH_WRAPPER_EXECUTABLES = new Set(withWindowsExeAliases(DISPAT const POSIX_SHELL_WRAPPER_CANONICAL = new Set(POSIX_SHELL_WRAPPER_NAMES); const WINDOWS_CMD_WRAPPER_CANONICAL = new Set(WINDOWS_CMD_WRAPPER_NAMES); const POWERSHELL_WRAPPER_CANONICAL = new Set(POWERSHELL_WRAPPER_NAMES); +const SHELL_MULTIPLEXER_WRAPPER_CANONICAL = new Set(SHELL_MULTIPLEXER_WRAPPER_NAMES); const DISPATCH_WRAPPER_CANONICAL = new Set(DISPATCH_WRAPPER_NAMES); const SHELL_WRAPPER_CANONICAL = new Set([ ...POSIX_SHELL_WRAPPER_NAMES, @@ -133,6 +135,39 @@ function findShellWrapperSpec(baseExecutable: string): ShellWrapperSpec | null { return null; } +export type ShellMultiplexerUnwrapResult = + | { kind: "not-wrapper" } + | { kind: "blocked"; wrapper: string } + | { kind: "unwrapped"; wrapper: string; argv: string[] }; + +export function unwrapKnownShellMultiplexerInvocation( + argv: string[], +): ShellMultiplexerUnwrapResult { + const token0 = argv[0]?.trim(); + if (!token0) { + return { kind: "not-wrapper" }; + } + const wrapper = normalizeExecutableToken(token0); + if (!SHELL_MULTIPLEXER_WRAPPER_CANONICAL.has(wrapper)) { + return { kind: "not-wrapper" }; + } + + let appletIndex = 1; + if (argv[appletIndex]?.trim() === "--") { + appletIndex += 1; + } + const applet = argv[appletIndex]?.trim(); + if (!applet || !isShellWrapperExecutable(applet)) { + return { kind: "blocked", wrapper }; + } + + const unwrapped = argv.slice(appletIndex); + if (unwrapped.length === 0) { + return { kind: "blocked", wrapper }; + } + return { kind: "unwrapped", wrapper, argv: unwrapped }; +} + export function isEnvAssignment(token: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token); } @@ -474,6 +509,18 @@ function hasEnvManipulationBeforeShellWrapperInternal( ); } + const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(argv); + if (shellMultiplexerUnwrap.kind === "blocked") { + return false; + } + if (shellMultiplexerUnwrap.kind === "unwrapped") { + return hasEnvManipulationBeforeShellWrapperInternal( + shellMultiplexerUnwrap.argv, + depth + 1, + envManipulationSeen, + ); + } + const wrapper = findShellWrapperSpec(normalizeExecutableToken(token0)); if (!wrapper) { return false; @@ -577,6 +624,14 @@ function extractShellWrapperCommandInternal( return extractShellWrapperCommandInternal(dispatchUnwrap.argv, rawCommand, depth + 1); } + const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(argv); + if (shellMultiplexerUnwrap.kind === "blocked") { + return { isWrapper: false, command: null }; + } + if (shellMultiplexerUnwrap.kind === "unwrapped") { + return extractShellWrapperCommandInternal(shellMultiplexerUnwrap.argv, rawCommand, depth + 1); + } + const base0 = normalizeExecutableToken(token0); const wrapper = findShellWrapperSpec(base0); if (!wrapper) { diff --git a/src/infra/system-run-command.test.ts b/src/infra/system-run-command.test.ts index 43a1b6fae79b..4b99c5e1365c 100644 --- a/src/infra/system-run-command.test.ts +++ b/src/infra/system-run-command.test.ts @@ -57,6 +57,11 @@ describe("system run command helpers", () => { expect(extractShellCommandFromArgv(["pwsh", "-Command", "Get-Date"])).toBe("Get-Date"); }); + test("extractShellCommandFromArgv unwraps busybox/toybox shell applets", () => { + expect(extractShellCommandFromArgv(["busybox", "sh", "-c", "echo hi"])).toBe("echo hi"); + expect(extractShellCommandFromArgv(["toybox", "ash", "-lc", "echo hi"])).toBe("echo hi"); + }); + test("extractShellCommandFromArgv ignores env wrappers when no shell wrapper follows", () => { expect(extractShellCommandFromArgv(["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"])).toBe( null, From 64aab802015a89fab110cae158eeaa040ff11901 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:10:05 +0000 Subject: [PATCH 010/408] test(exec): add regressions for safe-bin metadata and chain semantics --- src/infra/exec-approvals.test.ts | 65 ++++++++++++++++++++++++++ src/infra/exec-safe-bin-policy.test.ts | 34 ++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index cddc8cfbdf6f..49d2319dd325 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -680,6 +680,71 @@ describe("exec approvals allowlist evaluation", () => { expect(result.allowlistSatisfied).toBe(false); expect(result.segmentSatisfiedBy).toEqual([null]); }); + + it("returns empty segment details for chain misses", () => { + const segment = { + raw: "tool", + argv: ["tool"], + resolution: { + rawExecutable: "tool", + resolvedPath: "/usr/bin/tool", + executableName: "tool", + }, + }; + const analysis = { + ok: true, + segments: [segment], + chains: [[segment]], + }; + const result = evaluateExecAllowlist({ + analysis, + allowlist: [{ pattern: "/usr/bin/other" }], + safeBins: new Set(), + cwd: "/tmp", + }); + expect(result.allowlistSatisfied).toBe(false); + expect(result.allowlistMatches).toEqual([]); + expect(result.segmentSatisfiedBy).toEqual([]); + }); + + it("aggregates segment satisfaction across chains", () => { + const allowlistSegment = { + raw: "tool", + argv: ["tool"], + resolution: { + rawExecutable: "tool", + resolvedPath: "/usr/bin/tool", + executableName: "tool", + }, + }; + const safeBinSegment = { + raw: "jq .foo", + argv: ["jq", ".foo"], + resolution: { + rawExecutable: "jq", + resolvedPath: "/usr/bin/jq", + executableName: "jq", + }, + }; + const analysis = { + ok: true, + segments: [allowlistSegment, safeBinSegment], + chains: [[allowlistSegment], [safeBinSegment]], + }; + const result = evaluateExecAllowlist({ + analysis, + allowlist: [{ pattern: "/usr/bin/tool" }], + safeBins: normalizeSafeBins(["jq"]), + cwd: "/tmp", + }); + if (process.platform === "win32") { + expect(result.allowlistSatisfied).toBe(false); + return; + } + expect(result.allowlistSatisfied).toBe(true); + expect(result.allowlistMatches.map((entry) => entry.pattern)).toEqual(["/usr/bin/tool"]); + expect(result.segmentSatisfiedBy).toEqual(["allowlist", "safeBins"]); + }); }); describe("exec approvals policy helpers", () => { diff --git a/src/infra/exec-safe-bin-policy.test.ts b/src/infra/exec-safe-bin-policy.test.ts index 886e95ccce02..285b1465e530 100644 --- a/src/infra/exec-safe-bin-policy.test.ts +++ b/src/infra/exec-safe-bin-policy.test.ts @@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest"; import { SAFE_BIN_PROFILE_FIXTURES, SAFE_BIN_PROFILES, + buildLongFlagPrefixMap, + collectKnownLongFlags, renderSafeBinDeniedFlagsDocBullets, validateSafeBinArgv, } from "./exec-safe-bin-policy.js"; @@ -76,6 +78,38 @@ describe("exec safe bin policy wc", () => { }); }); +describe("exec safe bin policy long-option metadata", () => { + it("precomputes long-option prefix mappings for compiled profiles", () => { + const sortProfile = SAFE_BIN_PROFILES.sort; + expect(sortProfile.knownLongFlagsSet?.has("--compress-program")).toBe(true); + expect(sortProfile.longFlagPrefixMap?.get("--compress-prog")).toBe("--compress-program"); + expect(sortProfile.longFlagPrefixMap?.get("--f")).toBe(null); + }); + + it("preserves behavior when profile metadata is missing and rebuilt at runtime", () => { + const sortProfile = SAFE_BIN_PROFILES.sort; + const withoutMetadata = { + ...sortProfile, + knownLongFlags: undefined, + knownLongFlagsSet: undefined, + longFlagPrefixMap: undefined, + }; + expect(validateSafeBinArgv(["--compress-prog=sh"], withoutMetadata)).toBe(false); + expect(validateSafeBinArgv(["--totally-unknown=1"], withoutMetadata)).toBe(false); + }); + + it("builds prefix maps from collected long flags", () => { + const sortProfile = SAFE_BIN_PROFILES.sort; + const flags = collectKnownLongFlags( + sortProfile.allowedValueFlags ?? new Set(), + sortProfile.deniedFlags ?? new Set(), + ); + const prefixMap = buildLongFlagPrefixMap(flags); + expect(prefixMap.get("--compress-pr")).toBe("--compress-program"); + expect(prefixMap.get("--f")).toBe(null); + }); +}); + describe("exec safe bin policy denied-flag matrix", () => { for (const [binName, fixture] of Object.entries(SAFE_BIN_PROFILE_FIXTURES)) { const profile = SAFE_BIN_PROFILES[binName]; From 204d9fb404838221df19cc46b30e4cf53209d038 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:11:18 +0000 Subject: [PATCH 011/408] refactor(security): dedupe shell env probe and add path regression test --- src/agents/bash-tools.exec.path.test.ts | 42 +++++++++++++++- src/infra/shell-env.test.ts | 45 ++++++++--------- src/infra/shell-env.ts | 65 +++++++++++++++---------- 3 files changed, 100 insertions(+), 52 deletions(-) diff --git a/src/agents/bash-tools.exec.path.test.ts b/src/agents/bash-tools.exec.path.test.ts index 9bdbe07524c4..5481ec9668d9 100644 --- a/src/agents/bash-tools.exec.path.test.ts +++ b/src/agents/bash-tools.exec.path.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ExecApprovalsResolved } from "../infra/exec-approvals.js"; import { captureEnv } from "../test-utils/env.js"; @@ -67,7 +70,7 @@ describe("exec PATH login shell merge", () => { let envSnapshot: ReturnType; beforeEach(() => { - envSnapshot = captureEnv(["PATH"]); + envSnapshot = captureEnv(["PATH", "SHELL"]); }); afterEach(() => { @@ -112,6 +115,43 @@ describe("exec PATH login shell merge", () => { expect(shellPathMock).not.toHaveBeenCalled(); }); + + it("does not apply login-shell PATH when probe rejects unregistered absolute SHELL", async () => { + if (isWin) { + return; + } + process.env.PATH = "/usr/bin"; + const shellDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-env-")); + const unregisteredShellPath = path.join(shellDir, "unregistered-shell"); + fs.writeFileSync(unregisteredShellPath, '#!/bin/sh\nexec /bin/sh "$@"\n', { + encoding: "utf8", + mode: 0o755, + }); + process.env.SHELL = unregisteredShellPath; + + try { + const shellPathMock = vi.mocked(getShellPathFromLoginShell); + shellPathMock.mockClear(); + shellPathMock.mockImplementation((opts) => + opts.env.SHELL?.trim() === unregisteredShellPath ? null : "/custom/bin:/opt/bin", + ); + + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); + const result = await tool.execute("call1", { command: "echo $PATH" }); + const entries = normalizePathEntries(result.content.find((c) => c.type === "text")?.text); + + expect(entries).toEqual(["/usr/bin"]); + expect(shellPathMock).toHaveBeenCalledTimes(1); + expect(shellPathMock).toHaveBeenCalledWith( + expect.objectContaining({ + env: process.env, + timeoutMs: 1234, + }), + ); + } finally { + fs.rmSync(shellDir, { recursive: true, force: true }); + } + }); }); describe("exec host env validation", () => { diff --git a/src/infra/shell-env.test.ts b/src/infra/shell-env.test.ts index ab06202dc20b..644948b03c9a 100644 --- a/src/infra/shell-env.test.ts +++ b/src/infra/shell-env.test.ts @@ -59,6 +59,23 @@ describe("shell env fallback", () => { expect(receivedEnv?.HOME).toBe(os.homedir()); } + function withEtcShells(shells: string[], fn: () => void) { + const etcShellsContent = `${shells.join("\n")}\n`; + const readFileSyncSpy = vi + .spyOn(fs, "readFileSync") + .mockImplementation((filePath, encoding) => { + if (filePath === "/etc/shells" && encoding === "utf8") { + return etcShellsContent; + } + throw new Error(`Unexpected readFileSync(${String(filePath)}) in test`); + }); + try { + fn(); + } finally { + readFileSyncSpy.mockRestore(); + } + } + it("is disabled by default", () => { expect(shouldEnableShellEnvFallback({} as NodeJS.ProcessEnv)).toBe(false); expect(shouldEnableShellEnvFallback({ OPENCLAW_LOAD_SHELL_ENV: "0" })).toBe(false); @@ -172,44 +189,24 @@ describe("shell env fallback", () => { }); it("falls back to /bin/sh when SHELL is absolute but not registered in /etc/shells", () => { - const readFileSyncSpy = vi - .spyOn(fs, "readFileSync") - .mockImplementation((filePath, encoding) => { - if (filePath === "/etc/shells" && encoding === "utf8") { - return "/bin/sh\n/bin/bash\n/bin/zsh\n"; - } - throw new Error(`Unexpected readFileSync(${String(filePath)}) in test`); - }); - try { + withEtcShells(["/bin/sh", "/bin/bash", "/bin/zsh"], () => { const { res, exec } = runShellEnvFallbackForShell("/opt/homebrew/bin/evil-shell"); expect(res.ok).toBe(true); expect(exec).toHaveBeenCalledTimes(1); expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object)); - } finally { - readFileSyncSpy.mockRestore(); - } + }); }); it("uses SHELL when it is explicitly registered in /etc/shells", () => { - const readFileSyncSpy = vi - .spyOn(fs, "readFileSync") - .mockImplementation((filePath, encoding) => { - if (filePath === "/etc/shells" && encoding === "utf8") { - return "/bin/sh\n/usr/bin/zsh-trusted\n"; - } - throw new Error(`Unexpected readFileSync(${String(filePath)}) in test`); - }); - try { + withEtcShells(["/bin/sh", "/usr/bin/zsh-trusted"], () => { const trustedShell = "/usr/bin/zsh-trusted"; const { res, exec } = runShellEnvFallbackForShell(trustedShell); expect(res.ok).toBe(true); expect(exec).toHaveBeenCalledTimes(1); expect(exec).toHaveBeenCalledWith(trustedShell, ["-l", "-c", "env -0"], expect.any(Object)); - } finally { - readFileSyncSpy.mockRestore(); - } + }); }); it("sanitizes startup-related env vars before shell fallback exec", () => { diff --git a/src/infra/shell-env.ts b/src/infra/shell-env.ts index ac1369c48be5..796c19b2666c 100644 --- a/src/infra/shell-env.ts +++ b/src/infra/shell-env.ts @@ -110,6 +110,28 @@ function parseShellEnv(stdout: Buffer): Map { return shellEnv; } +type LoginShellEnvProbeResult = + | { ok: true; shellEnv: Map } + | { ok: false; error: string }; + +function probeLoginShellEnv(params: { + env: NodeJS.ProcessEnv; + timeoutMs?: number; + exec?: typeof execFileSync; +}): LoginShellEnvProbeResult { + const exec = params.exec ?? execFileSync; + const timeoutMs = resolveTimeoutMs(params.timeoutMs); + const shell = resolveShell(params.env); + const execEnv = resolveShellExecEnv(params.env); + + try { + const stdout = execLoginShellEnvZero({ shell, env: execEnv, exec, timeoutMs }); + return { ok: true, shellEnv: parseShellEnv(stdout) }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + export type ShellEnvFallbackResult = | { ok: true; applied: string[]; skippedReason?: never } | { ok: true; applied: []; skippedReason: "already-has-keys" | "disabled" } @@ -126,7 +148,6 @@ export type ShellEnvFallbackOptions = { export function loadShellEnvFallback(opts: ShellEnvFallbackOptions): ShellEnvFallbackResult { const logger = opts.logger ?? console; - const exec = opts.exec ?? execFileSync; if (!opts.enabled) { lastAppliedKeys = []; @@ -139,29 +160,23 @@ export function loadShellEnvFallback(opts: ShellEnvFallbackOptions): ShellEnvFal return { ok: true, applied: [], skippedReason: "already-has-keys" }; } - const timeoutMs = resolveTimeoutMs(opts.timeoutMs); - - const shell = resolveShell(opts.env); - const execEnv = resolveShellExecEnv(opts.env); - - let stdout: Buffer; - try { - stdout = execLoginShellEnvZero({ shell, env: execEnv, exec, timeoutMs }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.warn(`[openclaw] shell env fallback failed: ${msg}`); + const probe = probeLoginShellEnv({ + env: opts.env, + timeoutMs: opts.timeoutMs, + exec: opts.exec, + }); + if (!probe.ok) { + logger.warn(`[openclaw] shell env fallback failed: ${probe.error}`); lastAppliedKeys = []; - return { ok: false, error: msg, applied: [] }; + return { ok: false, error: probe.error, applied: [] }; } - const shellEnv = parseShellEnv(stdout); - const applied: string[] = []; for (const key of opts.expectedKeys) { if (opts.env[key]?.trim()) { continue; } - const value = shellEnv.get(key); + const value = probe.shellEnv.get(key); if (!value?.trim()) { continue; } @@ -208,21 +223,17 @@ export function getShellPathFromLoginShell(opts: { return cachedShellPath; } - const exec = opts.exec ?? execFileSync; - const timeoutMs = resolveTimeoutMs(opts.timeoutMs); - const shell = resolveShell(opts.env); - const execEnv = resolveShellExecEnv(opts.env); - - let stdout: Buffer; - try { - stdout = execLoginShellEnvZero({ shell, env: execEnv, exec, timeoutMs }); - } catch { + const probe = probeLoginShellEnv({ + env: opts.env, + timeoutMs: opts.timeoutMs, + exec: opts.exec, + }); + if (!probe.ok) { cachedShellPath = null; return cachedShellPath; } - const shellEnv = parseShellEnv(stdout); - const shellPath = shellEnv.get("PATH")?.trim(); + const shellPath = probe.shellEnv.get("PATH")?.trim(); cachedShellPath = shellPath && shellPath.length > 0 ? shellPath : null; return cachedShellPath; } From ffd63b7a2c4c6d5aeb4710ef951d5794ad7ad77b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:12:22 +0000 Subject: [PATCH 012/408] fix(security): trust resolved skill-bin paths in allowlist auto-allow --- CHANGELOG.md | 2 +- src/infra/exec-approvals-allowlist.ts | 93 ++++++++++++++++++++----- src/infra/exec-approvals.test.ts | 6 +- src/node-host/invoke-system-run.test.ts | 84 +++++++++++++++++++++- src/node-host/invoke-system-run.ts | 5 +- src/node-host/invoke-types.ts | 4 +- src/node-host/runner.ts | 81 +++++++++++++++++++-- 7 files changed, 243 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec759b137e0e..39a2596febbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Docs: https://docs.openclaw.ai - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. -- Security/Exec approvals: harden `autoAllowSkills` matching to require pathless invocations with resolved executables, blocking `./`/absolute-path basename collisions from satisfying skill auto-allow checks under allowlist mode. +- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @akhmittra for reporting. - Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index 25d06994977b..687ce3039ba2 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { DEFAULT_SAFE_BINS, analyzeShellCommand, @@ -104,6 +105,71 @@ export type ExecAllowlistEvaluation = { }; export type ExecSegmentSatisfiedBy = "allowlist" | "safeBins" | "skills" | null; +export type SkillBinTrustEntry = { + name: string; + resolvedPath: string; +}; + +function normalizeSkillBinName(value: string | undefined): string | null { + const trimmed = value?.trim().toLowerCase(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +function normalizeSkillBinResolvedPath(value: string | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + const resolved = path.resolve(trimmed); + if (process.platform === "win32") { + return resolved.replace(/\\/g, "/").toLowerCase(); + } + return resolved; +} + +function buildSkillBinTrustIndex( + entries: readonly SkillBinTrustEntry[] | undefined, +): Map> { + const trustByName = new Map>(); + if (!entries || entries.length === 0) { + return trustByName; + } + for (const entry of entries) { + const name = normalizeSkillBinName(entry.name); + const resolvedPath = normalizeSkillBinResolvedPath(entry.resolvedPath); + if (!name || !resolvedPath) { + continue; + } + const paths = trustByName.get(name) ?? new Set(); + paths.add(resolvedPath); + trustByName.set(name, paths); + } + return trustByName; +} + +function isSkillAutoAllowedSegment(params: { + segment: ExecCommandSegment; + allowSkills: boolean; + skillBinTrust: ReadonlyMap>; +}): boolean { + if (!params.allowSkills) { + return false; + } + const resolution = params.segment.resolution; + if (!resolution?.resolvedPath) { + return false; + } + const rawExecutable = resolution.rawExecutable?.trim() ?? ""; + if (!rawExecutable || isPathScopedExecutableToken(rawExecutable)) { + return false; + } + const executableName = normalizeSkillBinName(resolution.executableName); + const resolvedPath = normalizeSkillBinResolvedPath(resolution.resolvedPath); + if (!executableName || !resolvedPath) { + return false; + } + return Boolean(params.skillBinTrust.get(executableName)?.has(resolvedPath)); +} function evaluateSegments( segments: ExecCommandSegment[], @@ -114,7 +180,7 @@ function evaluateSegments( cwd?: string; platform?: string | null; trustedSafeBinDirs?: ReadonlySet; - skillBins?: Set; + skillBins?: readonly SkillBinTrustEntry[]; autoAllowSkills?: boolean; }, ): { @@ -123,7 +189,8 @@ function evaluateSegments( segmentSatisfiedBy: ExecSegmentSatisfiedBy[]; } { const matches: ExecAllowlistEntry[] = []; - const allowSkills = params.autoAllowSkills === true && (params.skillBins?.size ?? 0) > 0; + const skillBinTrust = buildSkillBinTrustIndex(params.skillBins); + const allowSkills = params.autoAllowSkills === true && skillBinTrust.size > 0; const segmentSatisfiedBy: ExecSegmentSatisfiedBy[] = []; const satisfied = segments.every((segment) => { @@ -152,19 +219,11 @@ function evaluateSegments( platform: params.platform, trustedSafeBinDirs: params.trustedSafeBinDirs, }); - const rawExecutable = segment.resolution?.rawExecutable?.trim() ?? ""; - const executableName = segment.resolution?.executableName; - const usesExplicitPath = isPathScopedExecutableToken(rawExecutable); - let skillAllow = false; - if ( - allowSkills && - segment.resolution?.resolvedPath && - rawExecutable.length > 0 && - !usesExplicitPath && - executableName - ) { - skillAllow = Boolean(params.skillBins?.has(executableName)); - } + const skillAllow = isSkillAutoAllowedSegment({ + segment, + allowSkills, + skillBinTrust, + }); const by: ExecSegmentSatisfiedBy = match ? "allowlist" : safe @@ -194,7 +253,7 @@ export function evaluateExecAllowlist(params: { cwd?: string; platform?: string | null; trustedSafeBinDirs?: ReadonlySet; - skillBins?: Set; + skillBins?: readonly SkillBinTrustEntry[]; autoAllowSkills?: boolean; }): ExecAllowlistEvaluation { const allowlistMatches: ExecAllowlistEntry[] = []; @@ -393,7 +452,7 @@ export function evaluateShellAllowlist(params: { cwd?: string; env?: NodeJS.ProcessEnv; trustedSafeBinDirs?: ReadonlySet; - skillBins?: Set; + skillBins?: readonly SkillBinTrustEntry[]; autoAllowSkills?: boolean; platform?: string | null; }): ExecAllowlistAnalysis { diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 49d2319dd325..6b405b466d31 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -621,7 +621,7 @@ describe("exec approvals allowlist evaluation", () => { analysis, allowlist: [], safeBins: new Set(), - skillBins: new Set(["skill-bin"]), + skillBins: [{ name: "skill-bin", resolvedPath: "/opt/skills/skill-bin" }], autoAllowSkills: true, cwd: "/tmp", }); @@ -647,7 +647,7 @@ describe("exec approvals allowlist evaluation", () => { analysis, allowlist: [], safeBins: new Set(), - skillBins: new Set(["skill-bin"]), + skillBins: [{ name: "skill-bin", resolvedPath: "/tmp/skill-bin" }], autoAllowSkills: true, cwd: "/tmp", }); @@ -673,7 +673,7 @@ describe("exec approvals allowlist evaluation", () => { analysis, allowlist: [], safeBins: new Set(), - skillBins: new Set(["skill-bin"]), + skillBins: [{ name: "skill-bin", resolvedPath: "/opt/skills/skill-bin" }], autoAllowSkills: true, cwd: "/tmp", }); diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 410382a5aad0..2c6c55bd1abd 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { saveExecApprovals } from "../infra/exec-approvals.js"; import type { ExecHostResponse } from "../infra/exec-host.js"; import { handleSystemRunInvoke, formatSystemRunAllowlistMissMessage } from "./invoke-system-run.js"; @@ -49,7 +50,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { sessionKey: "agent:main:main", }, skillBins: { - current: async () => new Set(), + current: async () => [], }, execHostEnforced: false, execHostFallbackAllowed: true, @@ -187,7 +188,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { sessionKey: "agent:main:main", }, skillBins: { - current: async () => new Set(), + current: async () => [], }, execHostEnforced: false, execHostFallbackAllowed: true, @@ -226,6 +227,85 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } }); + it("denies ./skill-bin even when autoAllowSkills trust entry exists", async () => { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-skill-path-spoof-")); + const previousOpenClawHome = process.env.OPENCLAW_HOME; + const skillBinPath = path.join(tempHome, "skill-bin"); + fs.writeFileSync(skillBinPath, "#!/bin/sh\necho should-not-run\n", { mode: 0o755 }); + fs.chmodSync(skillBinPath, 0o755); + process.env.OPENCLAW_HOME = tempHome; + saveExecApprovals({ + version: 1, + defaults: { + security: "allowlist", + ask: "on-miss", + askFallback: "deny", + autoAllowSkills: true, + }, + agents: {}, + }); + const runCommand = vi.fn(async () => ({ + success: true, + stdout: "local-ok", + stderr: "", + timedOut: false, + truncated: false, + exitCode: 0, + error: null, + })); + const sendInvokeResult = vi.fn(async () => {}); + const sendNodeEvent = vi.fn(async () => {}); + + try { + await handleSystemRunInvoke({ + client: {} as never, + params: { + command: ["./skill-bin", "--help"], + cwd: tempHome, + sessionKey: "agent:main:main", + }, + skillBins: { + current: async () => [{ name: "skill-bin", resolvedPath: skillBinPath }], + }, + execHostEnforced: false, + execHostFallbackAllowed: true, + resolveExecSecurity: () => "allowlist", + resolveExecAsk: () => "on-miss", + isCmdExeInvocation: () => false, + sanitizeEnv: () => undefined, + runCommand, + runViaMacAppExecHost: vi.fn(async () => null), + sendNodeEvent, + buildExecEventPayload: (payload) => payload, + sendInvokeResult, + sendExecFinishedEvent: vi.fn(async () => {}), + preferMacAppExecHost: false, + }); + } finally { + if (previousOpenClawHome === undefined) { + delete process.env.OPENCLAW_HOME; + } else { + process.env.OPENCLAW_HOME = previousOpenClawHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + + expect(runCommand).not.toHaveBeenCalled(); + expect(sendNodeEvent).toHaveBeenCalledWith( + expect.anything(), + "exec.denied", + expect.objectContaining({ reason: "approval-required" }), + ); + expect(sendInvokeResult).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: "SYSTEM_RUN_DENIED: approval required", + }), + }), + ); + }); + it("denies env -S shell payloads in allowlist mode", async () => { const { runCommand, sendInvokeResult } = await runSystemInvoke({ preferMacAppExecHost: false, diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index aeef1522fcc0..da97464966a3 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -14,6 +14,7 @@ import { type ExecAsk, type ExecCommandSegment, type ExecSecurity, + type SkillBinTrustEntry, } from "../infra/exec-approvals.js"; import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js"; import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; @@ -145,7 +146,7 @@ function evaluateSystemRunAllowlist(params: { trustedSafeBinDirs: ReturnType["trustedSafeBinDirs"]; cwd: string | undefined; env: Record | undefined; - skillBins: Set; + skillBins: SkillBinTrustEntry[]; autoAllowSkills: boolean; }): SystemRunAllowlistAnalysis { if (params.shellCommand) { @@ -310,7 +311,7 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): global: cfg.tools?.exec, local: agentExec, }); - const bins = autoAllowSkills ? await opts.skillBins.current() : new Set(); + const bins = autoAllowSkills ? await opts.skillBins.current() : []; let { analysisOk, allowlistMatches, allowlistSatisfied, segments } = evaluateSystemRunAllowlist({ shellCommand, argv, diff --git a/src/node-host/invoke-types.ts b/src/node-host/invoke-types.ts index ae41d56b9610..7246ba2925f0 100644 --- a/src/node-host/invoke-types.ts +++ b/src/node-host/invoke-types.ts @@ -1,3 +1,5 @@ +import type { SkillBinTrustEntry } from "../infra/exec-approvals.js"; + export type SystemRunParams = { command: string[]; rawCommand?: string | null; @@ -35,5 +37,5 @@ export type ExecEventPayload = { }; export type SkillBinsProvider = { - current(force?: boolean): Promise>; + current(force?: boolean): Promise; }; diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index e8b5df74f0ee..edf2cc122159 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -1,7 +1,10 @@ +import fs from "node:fs"; +import path from "node:path"; import { resolveBrowserConfig } from "../browser/config.js"; import { loadConfig } from "../config/config.js"; import { GatewayClient } from "../gateway/client.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; +import type { SkillBinTrustEntry } from "../infra/exec-approvals.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; @@ -27,17 +30,83 @@ type NodeHostRunOptions = { const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +function isExecutableFile(filePath: string): boolean { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + return false; + } + if (process.platform !== "win32") { + fs.accessSync(filePath, fs.constants.X_OK); + } + return true; + } catch { + return false; + } +} + +function resolveExecutablePathFromEnv(bin: string, pathEnv: string): string | null { + if (bin.includes("/") || bin.includes("\\")) { + return null; + } + const hasExtension = process.platform === "win32" && path.extname(bin).length > 0; + const extensions = + process.platform === "win32" + ? hasExtension + ? [""] + : (process.env.PATHEXT ?? process.env.PathExt ?? ".EXE;.CMD;.BAT;.COM") + .split(";") + .map((ext) => ext.toLowerCase()) + : [""]; + for (const dir of pathEnv.split(path.delimiter).filter(Boolean)) { + for (const ext of extensions) { + const candidate = path.join(dir, bin + ext); + if (isExecutableFile(candidate)) { + return candidate; + } + } + } + return null; +} + +function resolveSkillBinTrustEntries(bins: string[], pathEnv: string): SkillBinTrustEntry[] { + const trustEntries: SkillBinTrustEntry[] = []; + const seen = new Set(); + for (const bin of bins) { + const name = bin.trim(); + if (!name) { + continue; + } + const resolvedPath = resolveExecutablePathFromEnv(name, pathEnv); + if (!resolvedPath) { + continue; + } + const key = `${name}\u0000${resolvedPath}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + trustEntries.push({ name, resolvedPath }); + } + return trustEntries.toSorted( + (left, right) => + left.name.localeCompare(right.name) || left.resolvedPath.localeCompare(right.resolvedPath), + ); +} + class SkillBinsCache implements SkillBinsProvider { - private bins = new Set(); + private bins: SkillBinTrustEntry[] = []; private lastRefresh = 0; private readonly ttlMs = 90_000; private readonly fetch: () => Promise; + private readonly pathEnv: string; - constructor(fetch: () => Promise) { + constructor(fetch: () => Promise, pathEnv: string) { this.fetch = fetch; + this.pathEnv = pathEnv; } - async current(force = false): Promise> { + async current(force = false): Promise { if (force || Date.now() - this.lastRefresh > this.ttlMs) { await this.refresh(); } @@ -47,11 +116,11 @@ class SkillBinsCache implements SkillBinsProvider { private async refresh() { try { const bins = await this.fetch(); - this.bins = new Set(bins); + this.bins = resolveSkillBinTrustEntries(bins, this.pathEnv); this.lastRefresh = Date.now(); } catch { if (!this.lastRefresh) { - this.bins = new Set(); + this.bins = []; } } } @@ -155,7 +224,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { const res = await client.request<{ bins: Array }>("skills.bins", {}); const bins = Array.isArray(res?.bins) ? res.bins.map((bin) => String(bin)) : []; return bins; - }); + }, pathEnv); client.start(); await new Promise(() => {}); From c6c1e3e7cf7f3f13e31d69177949ca85172cb2f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:13:48 +0000 Subject: [PATCH 013/408] docs(changelog): correct exec approvals reporter credit --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39a2596febbc..2d95bdeba84b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Docs: https://docs.openclaw.ai - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. -- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @akhmittra for reporting. +- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. From 6f0dd61795122be95079b8afa020a47e15fcf1af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:16:26 +0000 Subject: [PATCH 014/408] fix(exec): restore two-phase approval registration flow --- .../bash-tools.exec-approval-request.test.ts | 40 ++++++- .../bash-tools.exec-approval-request.ts | 110 ++++++++++++++++-- src/agents/bash-tools.exec-host-gateway.ts | 45 ++++--- src/agents/bash-tools.exec-host-node.ts | 45 ++++--- .../bash-tools.exec.approval-id.test.ts | 11 +- 5 files changed, 211 insertions(+), 40 deletions(-) diff --git a/src/agents/bash-tools.exec-approval-request.test.ts b/src/agents/bash-tools.exec-approval-request.test.ts index a0722002c644..1cd221e300e1 100644 --- a/src/agents/bash-tools.exec-approval-request.test.ts +++ b/src/agents/bash-tools.exec-approval-request.test.ts @@ -22,7 +22,13 @@ describe("requestExecApprovalDecision", () => { }); it("returns string decisions", async () => { - vi.mocked(callGatewayTool).mockResolvedValue({ decision: "allow-once" }); + vi.mocked(callGatewayTool) + .mockResolvedValueOnce({ + status: "accepted", + id: "approval-id", + expiresAtMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }) + .mockResolvedValueOnce({ decision: "allow-once" }); const result = await requestExecApprovalDecision({ id: "approval-id", @@ -52,12 +58,22 @@ describe("requestExecApprovalDecision", () => { resolvedPath: "/usr/bin/echo", sessionKey: "session", timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + twoPhase: true, }, + { expectFinal: false }, + ); + expect(callGatewayTool).toHaveBeenNthCalledWith( + 2, + "exec.approval.waitDecision", + { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, + { id: "approval-id" }, ); }); it("returns null for missing or non-string decisions", async () => { - vi.mocked(callGatewayTool).mockResolvedValueOnce({}); + vi.mocked(callGatewayTool) + .mockResolvedValueOnce({ status: "accepted", id: "approval-id", expiresAtMs: 1234 }) + .mockResolvedValueOnce({}); await expect( requestExecApprovalDecision({ id: "approval-id", @@ -70,7 +86,9 @@ describe("requestExecApprovalDecision", () => { }), ).resolves.toBeNull(); - vi.mocked(callGatewayTool).mockResolvedValueOnce({ decision: 123 }); + vi.mocked(callGatewayTool) + .mockResolvedValueOnce({ status: "accepted", id: "approval-id-2", expiresAtMs: 1234 }) + .mockResolvedValueOnce({ decision: 123 }); await expect( requestExecApprovalDecision({ id: "approval-id-2", @@ -83,4 +101,20 @@ describe("requestExecApprovalDecision", () => { }), ).resolves.toBeNull(); }); + + it("returns final decision directly when gateway already replies with decision", async () => { + vi.mocked(callGatewayTool).mockResolvedValue({ decision: "deny", id: "approval-id" }); + + const result = await requestExecApprovalDecision({ + id: "approval-id", + command: "echo hi", + cwd: "/tmp", + host: "gateway", + security: "allowlist", + ask: "on-miss", + }); + + expect(result).toBe("deny"); + expect(vi.mocked(callGatewayTool).mock.calls).toHaveLength(1); + }); }); diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts index 7f0b59736d56..83323845c0cf 100644 --- a/src/agents/bash-tools.exec-approval-request.ts +++ b/src/agents/bash-tools.exec-approval-request.ts @@ -18,10 +18,45 @@ export type RequestExecApprovalDecisionParams = { sessionKey?: string; }; -export async function requestExecApprovalDecision( +type ParsedDecision = { present: boolean; value: string | null }; + +function parseDecision(value: unknown): ParsedDecision { + if (!value || typeof value !== "object") { + return { present: false, value: null }; + } + // Distinguish "field missing" from "field present but null/invalid". + // Registration responses intentionally omit `decision`; decision waits can include it. + if (!Object.hasOwn(value, "decision")) { + return { present: false, value: null }; + } + const decision = (value as { decision?: unknown }).decision; + return { present: true, value: typeof decision === "string" ? decision : null }; +} + +function parseString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function parseExpiresAtMs(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +export type ExecApprovalRegistration = { + id: string; + expiresAtMs: number; + finalDecision?: string | null; +}; + +export async function registerExecApprovalRequest( params: RequestExecApprovalDecisionParams, -): Promise { - const decisionResult = await callGatewayTool<{ decision: string }>( +): Promise { + // Two-phase registration is critical: the ID must be registered server-side + // before exec returns `approval-pending`, otherwise `/approve` can race and orphan. + const registrationResult = await callGatewayTool<{ + id?: string; + expiresAtMs?: number; + decision?: string; + }>( "exec.approval.request", { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, { @@ -36,13 +71,46 @@ export async function requestExecApprovalDecision( resolvedPath: params.resolvedPath, sessionKey: params.sessionKey, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + twoPhase: true, }, + { expectFinal: false }, ); - const decisionValue = - decisionResult && typeof decisionResult === "object" - ? (decisionResult as { decision?: unknown }).decision - : undefined; - return typeof decisionValue === "string" ? decisionValue : null; + const decision = parseDecision(registrationResult); + const id = parseString(registrationResult?.id) ?? params.id; + const expiresAtMs = + parseExpiresAtMs(registrationResult?.expiresAtMs) ?? Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; + if (decision.present) { + return { id, expiresAtMs, finalDecision: decision.value }; + } + return { id, expiresAtMs }; +} + +export async function waitForExecApprovalDecision(id: string): Promise { + try { + const decisionResult = await callGatewayTool<{ decision: string }>( + "exec.approval.waitDecision", + { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, + { id }, + ); + return parseDecision(decisionResult).value; + } catch (err) { + // Timeout/cleanup path: treat missing/expired as no decision so askFallback applies. + const message = String(err).toLowerCase(); + if (message.includes("approval expired or not found")) { + return null; + } + throw err; + } +} + +export async function requestExecApprovalDecision( + params: RequestExecApprovalDecisionParams, +): Promise { + const registration = await registerExecApprovalRequest(params); + if (Object.hasOwn(registration, "finalDecision")) { + return registration.finalDecision ?? null; + } + return await waitForExecApprovalDecision(registration.id); } export async function requestExecApprovalDecisionForHost(params: { @@ -70,3 +138,29 @@ export async function requestExecApprovalDecisionForHost(params: { sessionKey: params.sessionKey, }); } + +export async function registerExecApprovalRequestForHost(params: { + approvalId: string; + command: string; + workdir: string; + host: "gateway" | "node"; + nodeId?: string; + security: ExecSecurity; + ask: ExecAsk; + agentId?: string; + resolvedPath?: string; + sessionKey?: string; +}): Promise { + return await registerExecApprovalRequest({ + id: params.approvalId, + command: params.command, + cwd: params.workdir, + nodeId: params.nodeId, + host: params.host, + security: params.security, + ask: params.ask, + agentId: params.agentId, + resolvedPath: params.resolvedPath, + sessionKey: params.sessionKey, + }); +} diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index be81e703e135..607119109753 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -17,7 +17,10 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js"; import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js"; import { logInfo } from "../logger.js"; import { markBackgrounded, tail } from "./bash-process-registry.js"; -import { requestExecApprovalDecisionForHost } from "./bash-tools.exec-approval-request.js"; +import { + registerExecApprovalRequestForHost, + waitForExecApprovalDecision, +} from "./bash-tools.exec-approval-request.js"; import { DEFAULT_APPROVAL_TIMEOUT_MS, DEFAULT_NOTIFY_TAIL_CHARS, @@ -135,28 +138,42 @@ export async function processGatewayAllowlist( if (requiresAsk) { const approvalId = crypto.randomUUID(); const approvalSlug = createApprovalSlug(approvalId); - const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; const contextKey = `exec:${approvalId}`; const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath; const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)); const effectiveTimeout = typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec; const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : ""; + let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; + let preResolvedDecision: string | null | undefined; + + try { + // Register first so the returned approval ID is actionable immediately. + const registration = await registerExecApprovalRequestForHost({ + approvalId, + command: params.command, + workdir: params.workdir, + host: "gateway", + security: hostSecurity, + ask: hostAsk, + agentId: params.agentId, + resolvedPath, + sessionKey: params.sessionKey, + }); + expiresAtMs = registration.expiresAtMs; + preResolvedDecision = registration.finalDecision; + } catch (err) { + throw new Error(`Exec approval registration failed: ${String(err)}`, { cause: err }); + } void (async () => { - let decision: string | null = null; + let decision: string | null = preResolvedDecision ?? null; try { - decision = await requestExecApprovalDecisionForHost({ - approvalId, - command: params.command, - workdir: params.workdir, - host: "gateway", - security: hostSecurity, - ask: hostAsk, - agentId: params.agentId, - resolvedPath, - sessionKey: params.sessionKey, - }); + // Some gateways may return a final decision inline during registration. + // Only call waitDecision when registration did not already carry one. + if (preResolvedDecision === undefined) { + decision = await waitForExecApprovalDecision(approvalId); + } } catch { emitExecSystemEvent( `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`, diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index fc6893b93bf3..5a45c8692924 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -14,7 +14,10 @@ import { import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js"; import { buildNodeShellCommand } from "../infra/node-shell.js"; import { logInfo } from "../logger.js"; -import { requestExecApprovalDecisionForHost } from "./bash-tools.exec-approval-request.js"; +import { + registerExecApprovalRequestForHost, + waitForExecApprovalDecision, +} from "./bash-tools.exec-approval-request.js"; import { DEFAULT_APPROVAL_TIMEOUT_MS, createApprovalSlug, @@ -180,25 +183,39 @@ export async function executeNodeHostCommand( if (requiresAsk) { const approvalId = crypto.randomUUID(); const approvalSlug = createApprovalSlug(approvalId); - const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; const contextKey = `exec:${approvalId}`; const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)); const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : ""; + let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; + let preResolvedDecision: string | null | undefined; + + try { + // Register first so the returned approval ID is actionable immediately. + const registration = await registerExecApprovalRequestForHost({ + approvalId, + command: params.command, + workdir: params.workdir, + host: "node", + nodeId, + security: hostSecurity, + ask: hostAsk, + agentId: params.agentId, + sessionKey: params.sessionKey, + }); + expiresAtMs = registration.expiresAtMs; + preResolvedDecision = registration.finalDecision; + } catch (err) { + throw new Error(`Exec approval registration failed: ${String(err)}`, { cause: err }); + } void (async () => { - let decision: string | null = null; + let decision: string | null = preResolvedDecision ?? null; try { - decision = await requestExecApprovalDecisionForHost({ - approvalId, - command: params.command, - workdir: params.workdir, - host: "node", - nodeId, - security: hostSecurity, - ask: hostAsk, - agentId: params.agentId, - sessionKey: params.sessionKey, - }); + // Some gateways may return a final decision inline during registration. + // Only call waitDecision when registration did not already carry one. + if (preResolvedDecision === undefined) { + decision = await waitForExecApprovalDecision(approvalId); + } } catch { emitExecSystemEvent( `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`, diff --git a/src/agents/bash-tools.exec.approval-id.test.ts b/src/agents/bash-tools.exec.approval-id.test.ts index 4fb5b4bf495d..37a1215e5e62 100644 --- a/src/agents/bash-tools.exec.approval-id.test.ts +++ b/src/agents/bash-tools.exec.approval-id.test.ts @@ -65,7 +65,9 @@ describe("exec approvals", () => { vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { if (method === "exec.approval.request") { - // Approval request now carries the decision directly. + return { status: "accepted", id: (params as { id?: string })?.id }; + } + if (method === "exec.approval.waitDecision") { return { decision: "allow-once" }; } if (method === "node.invoke") { @@ -191,6 +193,7 @@ describe("exec approvals", () => { expect(result.details.status).toBe("approval-pending"); await approvalSeen; expect(calls).toContain("exec.approval.request"); + expect(calls).toContain("exec.approval.waitDecision"); }); it("denies node obfuscated command when approval request times out", async () => { @@ -204,6 +207,9 @@ describe("exec approvals", () => { vi.mocked(callGatewayTool).mockImplementation(async (method) => { calls.push(method); if (method === "exec.approval.request") { + return { status: "accepted", id: "approval-id" }; + } + if (method === "exec.approval.waitDecision") { return {}; } if (method === "node.invoke") { @@ -237,6 +243,9 @@ describe("exec approvals", () => { vi.mocked(callGatewayTool).mockImplementation(async (method) => { if (method === "exec.approval.request") { + return { status: "accepted", id: "approval-id" }; + } + if (method === "exec.approval.waitDecision") { return {}; } return { ok: true }; From 7b2b86c60abd7699cd9a19a66cd5ac994cc9acc3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:22:05 +0000 Subject: [PATCH 015/408] fix(exec): add approval race changelog and regressions --- CHANGELOG.md | 1 + .../bash-tools.exec-approval-request.test.ts | 49 +++++++++++++++ .../bash-tools.exec.approval-id.test.ts | 62 +++++++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d95bdeba84b..9a732763f334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: restore two-phase approval registration + wait-decision handling for gateway/node exec paths, requiring approval IDs to be registered before returning `approval-pending` and honoring server-assigned approval IDs during wait resolution to prevent orphaned `/approve` flows and immediate-return races (`ask:on-miss`). This ships in the next npm release. Thanks @vitalyis for reporting. - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. diff --git a/src/agents/bash-tools.exec-approval-request.test.ts b/src/agents/bash-tools.exec-approval-request.test.ts index 1cd221e300e1..c14a3f62b91e 100644 --- a/src/agents/bash-tools.exec-approval-request.test.ts +++ b/src/agents/bash-tools.exec-approval-request.test.ts @@ -102,6 +102,55 @@ describe("requestExecApprovalDecision", () => { ).resolves.toBeNull(); }); + it("uses registration response id when waiting for decision", async () => { + vi.mocked(callGatewayTool) + .mockResolvedValueOnce({ + status: "accepted", + id: "server-assigned-id", + expiresAtMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }) + .mockResolvedValueOnce({ decision: "allow-once" }); + + await expect( + requestExecApprovalDecision({ + id: "client-id", + command: "echo hi", + cwd: "/tmp", + host: "gateway", + security: "allowlist", + ask: "on-miss", + }), + ).resolves.toBe("allow-once"); + + expect(callGatewayTool).toHaveBeenNthCalledWith( + 2, + "exec.approval.waitDecision", + { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, + { id: "server-assigned-id" }, + ); + }); + + it("treats expired-or-missing waitDecision as null decision", async () => { + vi.mocked(callGatewayTool) + .mockResolvedValueOnce({ + status: "accepted", + id: "approval-id", + expiresAtMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }) + .mockRejectedValueOnce(new Error("approval expired or not found")); + + await expect( + requestExecApprovalDecision({ + id: "approval-id", + command: "echo hi", + cwd: "/tmp", + host: "gateway", + security: "allowlist", + ask: "on-miss", + }), + ).resolves.toBeNull(); + }); + it("returns final decision directly when gateway already replies with decision", async () => { vi.mocked(callGatewayTool).mockResolvedValue({ decision: "deny", id: "approval-id" }); diff --git a/src/agents/bash-tools.exec.approval-id.test.ts b/src/agents/bash-tools.exec.approval-id.test.ts index 37a1215e5e62..fc04efc0a632 100644 --- a/src/agents/bash-tools.exec.approval-id.test.ts +++ b/src/agents/bash-tools.exec.approval-id.test.ts @@ -196,6 +196,68 @@ describe("exec approvals", () => { expect(calls).toContain("exec.approval.waitDecision"); }); + it("waits for approval registration before returning approval-pending", async () => { + const calls: string[] = []; + let resolveRegistration: ((value: unknown) => void) | undefined; + const registrationPromise = new Promise((resolve) => { + resolveRegistration = resolve; + }); + + vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { + calls.push(method); + if (method === "exec.approval.request") { + return await registrationPromise; + } + if (method === "exec.approval.waitDecision") { + return { decision: "deny" }; + } + return { ok: true, id: (params as { id?: string })?.id }; + }); + + const tool = createExecTool({ + host: "gateway", + ask: "on-miss", + security: "allowlist", + approvalRunningNoticeMs: 0, + }); + + let settled = false; + const executePromise = tool.execute("call-registration-gate", { command: "echo register" }); + void executePromise.finally(() => { + settled = true; + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + + resolveRegistration?.({ status: "accepted", id: "approval-id" }); + const result = await executePromise; + expect(result.details.status).toBe("approval-pending"); + expect(calls[0]).toBe("exec.approval.request"); + expect(calls).toContain("exec.approval.waitDecision"); + }); + + it("fails fast when approval registration fails", async () => { + vi.mocked(callGatewayTool).mockImplementation(async (method) => { + if (method === "exec.approval.request") { + throw new Error("gateway offline"); + } + return { ok: true }; + }); + + const tool = createExecTool({ + host: "gateway", + ask: "on-miss", + security: "allowlist", + approvalRunningNoticeMs: 0, + }); + + await expect(tool.execute("call-registration-fail", { command: "echo fail" })).rejects.toThrow( + "Exec approval registration failed", + ); + }); + it("denies node obfuscated command when approval request times out", async () => { vi.mocked(detectCommandObfuscation).mockReturnValue({ detected: true, From 177f167eab203c659bb759dfb70eb8eba86d3c9d Mon Sep 17 00:00:00 2001 From: Omair Afzal <32237905+omair445@users.noreply.github.com> Date: Tue, 24 Feb 2026 08:22:39 +0500 Subject: [PATCH 016/408] fix: guard .trim() calls on potentially undefined workspaceDir (#24875) Change workspaceDir param type from string to string | undefined in resolvePluginSkillDirs and use nullish coalescing before .trim() to prevent TypeError when workspaceDir is undefined. --- src/agents/skills/plugin-skills.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/skills/plugin-skills.ts b/src/agents/skills/plugin-skills.ts index c3e7999fe875..90c8711cd744 100644 --- a/src/agents/skills/plugin-skills.ts +++ b/src/agents/skills/plugin-skills.ts @@ -12,10 +12,10 @@ import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; const log = createSubsystemLogger("skills"); export function resolvePluginSkillDirs(params: { - workspaceDir: string; + workspaceDir: string | undefined; config?: OpenClawConfig; }): string[] { - const workspaceDir = params.workspaceDir.trim(); + const workspaceDir = (params.workspaceDir ?? "").trim(); if (!workspaceDir) { return []; } From 19c43eade2ea227f7eb6ac9868d819fae0f00225 Mon Sep 17 00:00:00 2001 From: Omair Afzal <32237905+omair445@users.noreply.github.com> Date: Tue, 24 Feb 2026 08:22:42 +0500 Subject: [PATCH 017/408] fix(memory): strip null bytes from workspace paths causing ENOTDIR (#24876) Add stripNullBytes() helper and apply it to all return paths in resolveAgentWorkspaceDir() including configured, default, and state-dir-derived paths. Null bytes in paths cause ENOTDIR errors when Node tries to resolve them as directories. --- src/agents/agent-scope.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index c48cea9f6903..31fe49c0b761 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -13,6 +13,12 @@ import { normalizeSkillFilter } from "./skills/filter.js"; import { resolveDefaultAgentWorkspaceDir } from "./workspace.js"; const log = createSubsystemLogger("agent-scope"); +/** Strip null bytes from paths to prevent ENOTDIR errors. */ +function stripNullBytes(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/\0/g, ""); +} + export { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; type AgentEntry = NonNullable["list"]>[number]; @@ -214,18 +220,18 @@ export function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string) { const id = normalizeAgentId(agentId); const configured = resolveAgentConfig(cfg, id)?.workspace?.trim(); if (configured) { - return resolveUserPath(configured); + return stripNullBytes(resolveUserPath(configured)); } const defaultAgentId = resolveDefaultAgentId(cfg); if (id === defaultAgentId) { const fallback = cfg.agents?.defaults?.workspace?.trim(); if (fallback) { - return resolveUserPath(fallback); + return stripNullBytes(resolveUserPath(fallback)); } - return resolveDefaultAgentWorkspaceDir(process.env); + return stripNullBytes(resolveDefaultAgentWorkspaceDir(process.env)); } const stateDir = resolveStateDir(process.env); - return path.join(stateDir, `workspace-${id}`); + return stripNullBytes(path.join(stateDir, `workspace-${id}`)); } export function resolveAgentDir(cfg: OpenClawConfig, agentId: string) { From e2e10b3da49dcd75263a48c2198908d7027cf946 Mon Sep 17 00:00:00 2001 From: David Murray Date: Mon, 23 Feb 2026 19:22:45 -0800 Subject: [PATCH 018/408] fix(slack): map threadId to replyToId for restart sentinel notifications (#24885) The restart sentinel wake path passes threadId to deliverOutboundPayloads, but Slack requires replyToId (mapped to thread_ts) for threading. The agent reply path already does this conversion but the sentinel path did not, causing post-restart notifications to land as top-level DMs. Fixes #17716 --- src/gateway/server-restart-sentinel.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index e536193accde..454657d188d2 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -76,13 +76,22 @@ export async function scheduleRestartSentinelWake(_params: { deps: CliDeps }) { sessionThreadId ?? (origin?.threadId != null ? String(origin.threadId) : undefined); + // Slack uses replyToId (thread_ts) for threading, not threadId. + // The reply path does this mapping but deliverOutboundPayloads does not, + // so we must convert here to ensure post-restart notifications land in + // the originating Slack thread. See #17716. + const isSlack = channel === "slack"; + const replyToId = isSlack && threadId != null && threadId !== "" ? String(threadId) : undefined; + const resolvedThreadId = isSlack ? undefined : threadId; + try { await deliverOutboundPayloads({ cfg, channel, to: resolved.to, accountId: origin?.accountId, - threadId, + replyToId, + threadId: resolvedThreadId, payloads: [{ text: message }], agentId: resolveSessionAgentId({ sessionKey, config: cfg }), bestEffort: true, From 588ad7fb381a851b6c73c496eb069560eee0a33c Mon Sep 17 00:00:00 2001 From: Bill Cropper Date: Mon, 23 Feb 2026 22:22:48 -0500 Subject: [PATCH 019/408] fix: respect agent model config in slug generator (#24776) The slug generator was using hardcoded DEFAULT_PROVIDER and DEFAULT_MODEL instead of resolving from agent config. This caused it to fall back to anthropic/claude-opus-4-6 even when a cloud model was configured. Now uses resolveAgentModelPrimary() to get the configured model, with fallback to defaults if not configured. Fixes issue where session memory filenames would fail to generate when using cloud models that require special backends. --- src/hooks/llm-slug-generator.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/hooks/llm-slug-generator.ts b/src/hooks/llm-slug-generator.ts index f104cc4a7b89..33c69dcf5ed4 100644 --- a/src/hooks/llm-slug-generator.ts +++ b/src/hooks/llm-slug-generator.ts @@ -9,7 +9,10 @@ import { resolveDefaultAgentId, resolveAgentWorkspaceDir, resolveAgentDir, + resolveAgentModelPrimary, } from "../agents/agent-scope.js"; +import { DEFAULT_PROVIDER, DEFAULT_MODEL } from "../agents/defaults.js"; +import { parseModelRef } from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import type { OpenClawConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -41,6 +44,12 @@ ${params.sessionContent.slice(0, 2000)} Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", "bug-fix"`; + // Resolve model from agent config instead of using hardcoded defaults + const modelRef = resolveAgentModelPrimary(params.cfg, agentId); + const parsed = modelRef ? parseModelRef(modelRef, DEFAULT_PROVIDER) : null; + const provider = parsed?.provider ?? DEFAULT_PROVIDER; + const model = parsed?.model ?? DEFAULT_MODEL; + const result = await runEmbeddedPiAgent({ sessionId: `slug-generator-${Date.now()}`, sessionKey: "temp:slug-generator", @@ -50,6 +59,8 @@ Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", agentDir, config: params.cfg, prompt, + provider, + model, timeoutMs: 15_000, // 15 second timeout runId: `slug-gen-${Date.now()}`, }); From 70cfb69a5f12a47f95537e58e0726c2395dbdb20 Mon Sep 17 00:00:00 2001 From: Soumik Bhatta Date: Mon, 23 Feb 2026 22:22:52 -0500 Subject: [PATCH 020/408] fix(doctor): skip false positive permission warnings for Nix store symlinks (#24901) On NixOS/Nix-managed installs, config and state directories are symlinks into /nix/store/. Symlinks on Linux always report 0o777 via lstatSync, causing `openclaw doctor` to incorrectly warn about open permissions. Use lstatSync to detect symlinks, resolve the target, and only suppress the warning when the resolved path lives in /nix/store/ (an immutable filesystem). Symlinks to insecure targets still trigger warnings. Co-authored-by: Claude Opus 4.6 --- src/commands/doctor-state-integrity.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index bccb04964eb8..2e31da8e76a6 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -261,8 +261,15 @@ export async function noteStateIntegrity( } if (stateDirExists && process.platform !== "win32") { try { - const stat = fs.statSync(stateDir); - if ((stat.mode & 0o077) !== 0) { + const dirLstat = fs.lstatSync(stateDir); + const isDirSymlink = dirLstat.isSymbolicLink(); + // For symlinks, check the resolved target permissions instead of the + // symlink itself (which always reports 777). Skip the warning only when + // the target lives in a known immutable store (e.g. /nix/store/). + const stat = isDirSymlink ? fs.statSync(stateDir) : dirLstat; + const resolvedDir = isDirSymlink ? fs.realpathSync(stateDir) : stateDir; + const isImmutableStore = resolvedDir.startsWith("/nix/store/"); + if (!isImmutableStore && (stat.mode & 0o077) !== 0) { warnings.push( `- State directory permissions are too open (${displayStateDir}). Recommend chmod 700.`, ); @@ -282,10 +289,14 @@ export async function noteStateIntegrity( if (configPath && existsFile(configPath) && process.platform !== "win32") { try { - const linkStat = fs.lstatSync(configPath); - const stat = fs.statSync(configPath); - const isSymlink = linkStat.isSymbolicLink(); - if (!isSymlink && (stat.mode & 0o077) !== 0) { + const configLstat = fs.lstatSync(configPath); + const isSymlink = configLstat.isSymbolicLink(); + // For symlinks, check the resolved target permissions. Skip the warning + // only when the target lives in an immutable store (e.g. /nix/store/). + const stat = isSymlink ? fs.statSync(configPath) : configLstat; + const resolvedConfig = isSymlink ? fs.realpathSync(configPath) : configPath; + const isImmutableConfig = resolvedConfig.startsWith("/nix/store/"); + if (!isImmutableConfig && (stat.mode & 0o077) !== 0) { warnings.push( `- Config file is group/world readable (${displayConfigPath ?? configPath}). Recommend chmod 600.`, ); From dc8423f2c0dc6bc2a7708402c494667185d85341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E9=9B=B2?= <137844255@qq.com> Date: Tue, 24 Feb 2026 11:22:55 +0800 Subject: [PATCH 021/408] fix: back up existing systemd unit before overwriting on update (#24350) (#24937) When `openclaw update` regenerates the systemd service file, any user customizations to ExecStart (e.g. proxychains4 wrapper) are silently lost. Now the existing unit file is copied to `.bak` before writing the new one, so users can restore their customizations. The backup path is printed in the install output so users are aware. Co-authored-by: echoVic --- src/daemon/systemd.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 0a295436df88..0e1dc5541bad 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -193,6 +193,18 @@ export async function installSystemdService({ const unitPath = resolveSystemdUnitPath(env); await fs.mkdir(path.dirname(unitPath), { recursive: true }); + + // Preserve user customizations: back up existing unit file before overwriting. + let backedUp = false; + try { + await fs.access(unitPath); + const backupPath = `${unitPath}.bak`; + await fs.copyFile(unitPath, backupPath); + backedUp = true; + } catch { + // File does not exist yet — nothing to back up. + } + const serviceDescription = resolveGatewayServiceDescription({ env, environment, description }); const unit = buildSystemdUnit({ description: serviceDescription, @@ -227,6 +239,14 @@ export async function installSystemdService({ label: "Installed systemd service", value: unitPath, }, + ...(backedUp + ? [ + { + label: "Previous unit backed up to", + value: `${unitPath}.bak`, + }, + ] + : []), ], { leadingBlankLine: true }, ); From d07d24eebefe1d919260555f7deb71825778ee39 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 23 Feb 2026 19:22:58 -0800 Subject: [PATCH 022/408] fix: clamp poll sleep duration to non-negative in bash-tools process (#24889) `Math.min(250, deadline - Date.now())` could return a negative value if the deadline expired between the while-condition check and the setTimeout call. Wrap with `Math.max(0, ...)` to ensure the sleep is never negative. Co-authored-by: Claude Sonnet 4.6 --- src/agents/bash-tools.process.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index 25248bf22183..028f56bbb75c 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -331,7 +331,7 @@ export function createProcessTool( const deadline = Date.now() + pollWaitMs; while (!scopedSession.exited && Date.now() < deadline) { await new Promise((resolve) => - setTimeout(resolve, Math.min(250, deadline - Date.now())), + setTimeout(resolve, Math.max(0, Math.min(250, deadline - Date.now()))), ); } } From 237b9be937c8975c39b16814935af8eb0065b6bc Mon Sep 17 00:00:00 2001 From: Ali Al Jufairi <20195330@stu.uob.edu.bh> Date: Tue, 24 Feb 2026 12:23:01 +0900 Subject: [PATCH 023/408] chore(docs) : remove the mention of Anthropic OAuth since it is not allowed according to there new guidlines (#24989) --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 3cc1bacfc3fb..7387372192f9 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,6 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin **Subscriptions (OAuth):** -- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max) - **[OpenAI](https://openai.com/)** (ChatGPT/Codex) Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding). From 9df80b73e26e23114b57e11b7532bfb05d450ec3 Mon Sep 17 00:00:00 2001 From: User Date: Tue, 24 Feb 2026 10:47:11 +0800 Subject: [PATCH 024/408] fix: allow RFC2544 benchmark range (198.18.0.0/15) through SSRF filter Telegram's API and file servers resolve to IPs in the 198.18.0.0/15 range (RFC 2544 benchmarking range). The SSRF filter was blocking these addresses because ipaddr.js classifies them as 'reserved', and the filter also had an explicit RFC2544_BENCHMARK_PREFIX check that blocked them unconditionally. Fix: exempt 198.18.0.0/15 from the 'reserved' range block in isBlockedSpecialUseIpv4Address(). Other 'reserved' ranges (TEST-NET-2, TEST-NET-3, documentation prefixes) remain blocked. The explicit RFC2544_BENCHMARK_PREFIX check is repurposed as the exemption guard. Closes #24973 --- src/infra/net/fetch-guard.ssrf.test.ts | 16 +++++++++++++++- src/infra/net/ssrf.pinning.test.ts | 10 +++++++++- src/infra/net/ssrf.test.ts | 17 +++++++++++------ src/shared/net/ip.ts | 16 +++++++++++++--- 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index de0140d76a25..7d5b4090a6ae 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -37,13 +37,27 @@ describe("fetchWithSsrFGuard hardening", () => { const fetchImpl = vi.fn(); await expect( fetchWithSsrFGuard({ - url: "http://198.18.0.1:8080/internal", + url: "http://198.51.100.1:8080/internal", fetchImpl, }), ).rejects.toThrow(/private|internal|blocked/i); expect(fetchImpl).not.toHaveBeenCalled(); }); + it("allows RFC2544 benchmark range IPv4 literal URLs (Telegram)", async () => { + const lookupFn = vi.fn(async () => [ + { address: "198.18.0.153", family: 4 }, + ]) as unknown as LookupFn; + const fetchImpl = vi.fn().mockResolvedValueOnce(new Response("ok", { status: 200 })); + // Should not throw — 198.18.x.x is allowed now + const result = await fetchWithSsrFGuard({ + url: "http://198.18.0.153/file", + fetchImpl, + lookupFn, + }); + expect(result.response.status).toBe(200); + }); + it("blocks redirect chains that hop to private hosts", async () => { const lookupFn = vi.fn(async () => [ { address: "93.184.216.34", family: 4 }, diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 660b8b6df6b0..7ae0242c0549 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -51,12 +51,20 @@ describe("ssrf pinning", () => { it.each([ { name: "RFC1918 private address", address: "10.0.0.8" }, - { name: "RFC2544 benchmarking range", address: "198.18.0.1" }, + { name: "TEST-NET-2 reserved range", address: "198.51.100.1" }, ])("rejects blocked DNS results: $name", async ({ address }) => { const lookup = vi.fn(async () => [{ address, family: 4 }]) as unknown as LookupFn; await expect(resolvePinnedHostname("example.com", lookup)).rejects.toThrow(/private|internal/i); }); + it("allows RFC2544 benchmark range addresses (used by Telegram)", async () => { + const lookup = vi.fn(async () => [ + { address: "198.18.0.153", family: 4 }, + ]) as unknown as LookupFn; + const pinned = await resolvePinnedHostname("api.telegram.org", lookup); + expect(pinned.addresses).toContain("198.18.0.153"); + }); + it("falls back for non-matching hostnames", async () => { const fallback = vi.fn((host: string, options?: unknown, callback?: unknown) => { const cb = typeof options === "function" ? options : (callback as () => void); diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 5d8fe8f66204..1bb2d77dbd5c 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -3,8 +3,6 @@ import { normalizeFingerprint } from "../tls/fingerprint.js"; import { isBlockedHostnameOrIp, isPrivateIpAddress } from "./ssrf.js"; const privateIpCases = [ - "198.18.0.1", - "198.19.255.254", "198.51.100.42", "203.0.113.10", "192.0.0.8", @@ -15,7 +13,6 @@ const privateIpCases = [ "240.0.0.1", "255.255.255.255", "::ffff:127.0.0.1", - "::ffff:198.18.0.1", "64:ff9b::198.51.100.42", "0:0:0:0:0:ffff:7f00:1", "0000:0000:0000:0000:0000:ffff:7f00:0001", @@ -32,7 +29,6 @@ const privateIpCases = [ "2002:a9fe:a9fe::", "2001:0000:0:0:0:0:80ff:fefe", "2001:0000:0:0:0:0:3f57:fefe", - "2002:c612:0001::", "::", "::1", "fe80::1%lo0", @@ -45,13 +41,18 @@ const privateIpCases = [ const publicIpCases = [ "93.184.216.34", "198.17.255.255", + "198.18.0.1", + "198.18.0.153", + "198.19.255.254", "198.20.0.1", + "2002:c612:0001::", "198.51.99.1", "198.51.101.1", "203.0.112.1", "203.0.114.1", "223.255.255.255", "2606:4700:4700::1111", + "::ffff:198.18.0.1", "2001:db8::1", "64:ff9b::8.8.8.8", "64:ff9b:1::8.8.8.8", @@ -119,9 +120,13 @@ describe("isBlockedHostnameOrIp", () => { expect(isBlockedHostnameOrIp("2001:db8::1")).toBe(false); }); - it("blocks IPv4 special-use ranges but allows adjacent public ranges", () => { - expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(true); + it("allows RFC2544 benchmark range (used by Telegram) but blocks adjacent special-use ranges", () => { + expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(false); + expect(isBlockedHostnameOrIp("198.18.0.153")).toBe(false); + expect(isBlockedHostnameOrIp("198.19.255.254")).toBe(false); expect(isBlockedHostnameOrIp("198.20.0.1")).toBe(false); + expect(isBlockedHostnameOrIp("198.51.100.1")).toBe(true); + expect(isBlockedHostnameOrIp("203.0.113.1")).toBe(true); }); it("blocks legacy IPv4 literal representations", () => { diff --git a/src/shared/net/ip.ts b/src/shared/net/ip.ts index 21770a20e29b..a6b84ddd09ea 100644 --- a/src/shared/net/ip.ts +++ b/src/shared/net/ip.ts @@ -28,6 +28,12 @@ const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set([ "linkLocal", "uniqueLocal", ]); +/** + * RFC 2544 benchmark range (198.18.0.0/15). Originally reserved for network + * device benchmarking, but in practice used by real services — notably + * Telegram's API/file servers resolve to addresses in this block. We + * therefore exempt it from the SSRF block list. + */ const RFC2544_BENCHMARK_PREFIX: [ipaddr.IPv4, number] = [ipaddr.IPv4.parse("198.18.0.0"), 15]; const EMBEDDED_IPV4_SENTINEL_RULES: Array<{ @@ -248,9 +254,13 @@ export function isCarrierGradeNatIpv4Address(raw: string | undefined): boolean { } export function isBlockedSpecialUseIpv4Address(address: ipaddr.IPv4): boolean { - return ( - BLOCKED_IPV4_SPECIAL_USE_RANGES.has(address.range()) || address.match(RFC2544_BENCHMARK_PREFIX) - ); + const range = address.range(); + if (range === "reserved" && address.match(RFC2544_BENCHMARK_PREFIX)) { + // 198.18.0.0/15 is classified as "reserved" by ipaddr.js but is used by + // real public services (e.g. Telegram API). Allow it through. + return false; + } + return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(range); } function decodeIpv4FromHextets(high: number, low: number): ipaddr.IPv4 { From 3af9d1f8e912873c60dfe34c265c4119b4ab9e68 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:27:40 +0000 Subject: [PATCH 025/408] fix: scope Telegram RFC2544 SSRF exception to policy opt-in (#24982) (thanks @stakeswky) --- CHANGELOG.md | 1 + src/infra/net/fetch-guard.ssrf.test.ts | 10 ++---- src/infra/net/ssrf.pinning.test.ts | 13 +++++-- src/infra/net/ssrf.test.ts | 25 ++++++++------ src/infra/net/ssrf.ts | 34 +++++++++++++------ src/shared/net/ip.ts | 22 ++++++------ .../bot/delivery.resolve-media-retry.test.ts | 6 ++++ src/telegram/bot/delivery.ts | 4 +++ 8 files changed, 72 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a732763f334..7a7cc7560768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. - Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: restore two-phase approval registration + wait-decision handling for gateway/node exec paths, requiring approval IDs to be registered before returning `approval-pending` and honoring server-assigned approval IDs during wait resolution to prevent orphaned `/approve` flows and immediate-return races (`ask:on-miss`). This ships in the next npm release. Thanks @vitalyis for reporting. diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 7d5b4090a6ae..a03afba325f6 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -37,23 +37,19 @@ describe("fetchWithSsrFGuard hardening", () => { const fetchImpl = vi.fn(); await expect( fetchWithSsrFGuard({ - url: "http://198.51.100.1:8080/internal", + url: "http://198.18.0.1:8080/internal", fetchImpl, }), ).rejects.toThrow(/private|internal|blocked/i); expect(fetchImpl).not.toHaveBeenCalled(); }); - it("allows RFC2544 benchmark range IPv4 literal URLs (Telegram)", async () => { - const lookupFn = vi.fn(async () => [ - { address: "198.18.0.153", family: 4 }, - ]) as unknown as LookupFn; + it("allows RFC2544 benchmark range IPv4 literal URLs when explicitly opted in", async () => { const fetchImpl = vi.fn().mockResolvedValueOnce(new Response("ok", { status: 200 })); - // Should not throw — 198.18.x.x is allowed now const result = await fetchWithSsrFGuard({ url: "http://198.18.0.153/file", fetchImpl, - lookupFn, + policy: { allowRfc2544BenchmarkRange: true }, }); expect(result.response.status).toBe(200); }); diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 7ae0242c0549..19d61bdaee84 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -51,17 +51,26 @@ describe("ssrf pinning", () => { it.each([ { name: "RFC1918 private address", address: "10.0.0.8" }, + { name: "RFC2544 benchmarking range", address: "198.18.0.1" }, { name: "TEST-NET-2 reserved range", address: "198.51.100.1" }, ])("rejects blocked DNS results: $name", async ({ address }) => { const lookup = vi.fn(async () => [{ address, family: 4 }]) as unknown as LookupFn; await expect(resolvePinnedHostname("example.com", lookup)).rejects.toThrow(/private|internal/i); }); - it("allows RFC2544 benchmark range addresses (used by Telegram)", async () => { + it("allows RFC2544 benchmark range addresses only when policy explicitly opts in", async () => { const lookup = vi.fn(async () => [ { address: "198.18.0.153", family: 4 }, ]) as unknown as LookupFn; - const pinned = await resolvePinnedHostname("api.telegram.org", lookup); + + await expect(resolvePinnedHostname("api.telegram.org", lookup)).rejects.toThrow( + /private|internal/i, + ); + + const pinned = await resolvePinnedHostnameWithPolicy("api.telegram.org", { + lookupFn: lookup, + policy: { allowRfc2544BenchmarkRange: true }, + }); expect(pinned.addresses).toContain("198.18.0.153"); }); diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 1bb2d77dbd5c..5826669196db 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -3,6 +3,8 @@ import { normalizeFingerprint } from "../tls/fingerprint.js"; import { isBlockedHostnameOrIp, isPrivateIpAddress } from "./ssrf.js"; const privateIpCases = [ + "198.18.0.1", + "198.19.255.254", "198.51.100.42", "203.0.113.10", "192.0.0.8", @@ -13,6 +15,7 @@ const privateIpCases = [ "240.0.0.1", "255.255.255.255", "::ffff:127.0.0.1", + "::ffff:198.18.0.1", "64:ff9b::198.51.100.42", "0:0:0:0:0:ffff:7f00:1", "0000:0000:0000:0000:0000:ffff:7f00:0001", @@ -29,6 +32,7 @@ const privateIpCases = [ "2002:a9fe:a9fe::", "2001:0000:0:0:0:0:80ff:fefe", "2001:0000:0:0:0:0:3f57:fefe", + "2002:c612:0001::", "::", "::1", "fe80::1%lo0", @@ -41,18 +45,13 @@ const privateIpCases = [ const publicIpCases = [ "93.184.216.34", "198.17.255.255", - "198.18.0.1", - "198.18.0.153", - "198.19.255.254", "198.20.0.1", - "2002:c612:0001::", "198.51.99.1", "198.51.101.1", "203.0.112.1", "203.0.114.1", "223.255.255.255", "2606:4700:4700::1111", - "::ffff:198.18.0.1", "2001:db8::1", "64:ff9b::8.8.8.8", "64:ff9b:1::8.8.8.8", @@ -120,13 +119,17 @@ describe("isBlockedHostnameOrIp", () => { expect(isBlockedHostnameOrIp("2001:db8::1")).toBe(false); }); - it("allows RFC2544 benchmark range (used by Telegram) but blocks adjacent special-use ranges", () => { - expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(false); - expect(isBlockedHostnameOrIp("198.18.0.153")).toBe(false); - expect(isBlockedHostnameOrIp("198.19.255.254")).toBe(false); + it("blocks IPv4 special-use ranges but allows adjacent public ranges", () => { + expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(true); expect(isBlockedHostnameOrIp("198.20.0.1")).toBe(false); - expect(isBlockedHostnameOrIp("198.51.100.1")).toBe(true); - expect(isBlockedHostnameOrIp("203.0.113.1")).toBe(true); + }); + + it("supports opt-in policy to allow RFC2544 benchmark range", () => { + const policy = { allowRfc2544BenchmarkRange: true }; + expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(true); + expect(isBlockedHostnameOrIp("198.18.0.1", policy)).toBe(false); + expect(isBlockedHostnameOrIp("::ffff:198.18.0.1", policy)).toBe(false); + expect(isBlockedHostnameOrIp("198.51.100.1", policy)).toBe(true); }); it("blocks legacy IPv4 literal representations", () => { diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 3a4456e7839f..2e4c69210d6c 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -5,6 +5,7 @@ import { extractEmbeddedIpv4FromIpv6, isBlockedSpecialUseIpv4Address, isCanonicalDottedDecimalIPv4, + type Ipv4SpecialUseBlockOptions, isIpv4Address, isLegacyIpv4Literal, isPrivateOrLoopbackIpAddress, @@ -31,6 +32,7 @@ export type LookupFn = typeof dnsLookup; export type SsrFPolicy = { allowPrivateNetwork?: boolean; dangerouslyAllowPrivateNetwork?: boolean; + allowRfc2544BenchmarkRange?: boolean; allowedHostnames?: string[]; hostnameAllowlist?: string[]; }; @@ -65,6 +67,12 @@ function resolveAllowPrivateNetwork(policy?: SsrFPolicy): boolean { return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true; } +function resolveIpv4SpecialUseBlockOptions(policy?: SsrFPolicy): Ipv4SpecialUseBlockOptions { + return { + allowRfc2544BenchmarkRange: policy?.allowRfc2544BenchmarkRange === true, + }; +} + function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean { if (pattern.startsWith("*.")) { const suffix = pattern.slice(2); @@ -97,7 +105,7 @@ function looksLikeUnsupportedIpv4Literal(address: string): boolean { } // Returns true for private/internal and special-use non-global addresses. -export function isPrivateIpAddress(address: string): boolean { +export function isPrivateIpAddress(address: string, policy?: SsrFPolicy): boolean { let normalized = address.trim().toLowerCase(); if (normalized.startsWith("[") && normalized.endsWith("]")) { normalized = normalized.slice(1, -1); @@ -105,18 +113,19 @@ export function isPrivateIpAddress(address: string): boolean { if (!normalized) { return false; } + const blockOptions = resolveIpv4SpecialUseBlockOptions(policy); const strictIp = parseCanonicalIpAddress(normalized); if (strictIp) { if (isIpv4Address(strictIp)) { - return isBlockedSpecialUseIpv4Address(strictIp); + return isBlockedSpecialUseIpv4Address(strictIp, blockOptions); } if (isPrivateOrLoopbackIpAddress(strictIp.toString())) { return true; } const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(strictIp); if (embeddedIpv4) { - return isBlockedSpecialUseIpv4Address(embeddedIpv4); + return isBlockedSpecialUseIpv4Address(embeddedIpv4, blockOptions); } return false; } @@ -154,27 +163,30 @@ function isBlockedHostnameNormalized(normalized: string): boolean { ); } -export function isBlockedHostnameOrIp(hostname: string): boolean { +export function isBlockedHostnameOrIp(hostname: string, policy?: SsrFPolicy): boolean { const normalized = normalizeHostname(hostname); if (!normalized) { return false; } - return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized); + return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized, policy); } const BLOCKED_HOST_OR_IP_MESSAGE = "Blocked hostname or private/internal/special-use IP address"; const BLOCKED_RESOLVED_IP_MESSAGE = "Blocked: resolves to private/internal/special-use IP address"; -function assertAllowedHostOrIpOrThrow(hostnameOrIp: string): void { - if (isBlockedHostnameOrIp(hostnameOrIp)) { +function assertAllowedHostOrIpOrThrow(hostnameOrIp: string, policy?: SsrFPolicy): void { + if (isBlockedHostnameOrIp(hostnameOrIp, policy)) { throw new SsrFBlockedError(BLOCKED_HOST_OR_IP_MESSAGE); } } -function assertAllowedResolvedAddressesOrThrow(results: readonly LookupAddress[]): void { +function assertAllowedResolvedAddressesOrThrow( + results: readonly LookupAddress[], + policy?: SsrFPolicy, +): void { for (const entry of results) { // Reuse the exact same host/IP classifier as the pre-DNS check to avoid drift. - if (isBlockedHostnameOrIp(entry.address)) { + if (isBlockedHostnameOrIp(entry.address, policy)) { throw new SsrFBlockedError(BLOCKED_RESOLVED_IP_MESSAGE); } } @@ -264,7 +276,7 @@ export async function resolvePinnedHostnameWithPolicy( if (!skipPrivateNetworkChecks) { // Phase 1: fail fast for literal hosts/IPs before any DNS lookup side-effects. - assertAllowedHostOrIpOrThrow(normalized); + assertAllowedHostOrIpOrThrow(normalized, params.policy); } const lookupFn = params.lookupFn ?? dnsLookup; @@ -275,7 +287,7 @@ export async function resolvePinnedHostnameWithPolicy( if (!skipPrivateNetworkChecks) { // Phase 2: re-check DNS answers so public hostnames cannot pivot to private targets. - assertAllowedResolvedAddressesOrThrow(results); + assertAllowedResolvedAddressesOrThrow(results, params.policy); } const addresses = Array.from(new Set(results.map((entry) => entry.address))); diff --git a/src/shared/net/ip.ts b/src/shared/net/ip.ts index a6b84ddd09ea..2342bdedafe0 100644 --- a/src/shared/net/ip.ts +++ b/src/shared/net/ip.ts @@ -28,13 +28,10 @@ const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set([ "linkLocal", "uniqueLocal", ]); -/** - * RFC 2544 benchmark range (198.18.0.0/15). Originally reserved for network - * device benchmarking, but in practice used by real services — notably - * Telegram's API/file servers resolve to addresses in this block. We - * therefore exempt it from the SSRF block list. - */ const RFC2544_BENCHMARK_PREFIX: [ipaddr.IPv4, number] = [ipaddr.IPv4.parse("198.18.0.0"), 15]; +export type Ipv4SpecialUseBlockOptions = { + allowRfc2544BenchmarkRange?: boolean; +}; const EMBEDDED_IPV4_SENTINEL_RULES: Array<{ matches: (parts: number[]) => boolean; @@ -253,14 +250,15 @@ export function isCarrierGradeNatIpv4Address(raw: string | undefined): boolean { return parsed.range() === "carrierGradeNat"; } -export function isBlockedSpecialUseIpv4Address(address: ipaddr.IPv4): boolean { - const range = address.range(); - if (range === "reserved" && address.match(RFC2544_BENCHMARK_PREFIX)) { - // 198.18.0.0/15 is classified as "reserved" by ipaddr.js but is used by - // real public services (e.g. Telegram API). Allow it through. +export function isBlockedSpecialUseIpv4Address( + address: ipaddr.IPv4, + options: Ipv4SpecialUseBlockOptions = {}, +): boolean { + const inRfc2544BenchmarkRange = address.match(RFC2544_BENCHMARK_PREFIX); + if (inRfc2544BenchmarkRange && options.allowRfc2544BenchmarkRange === true) { return false; } - return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(range); + return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(address.range()) || inRfc2544BenchmarkRange; } function decodeIpv4FromHextets(high: number, low: number): ipaddr.IPv4 { diff --git a/src/telegram/bot/delivery.resolve-media-retry.test.ts b/src/telegram/bot/delivery.resolve-media-retry.test.ts index 2c54396a8342..2becbcd93e9e 100644 --- a/src/telegram/bot/delivery.resolve-media-retry.test.ts +++ b/src/telegram/bot/delivery.resolve-media-retry.test.ts @@ -92,6 +92,12 @@ async function expectTransientGetFileRetrySuccess() { await flushRetryTimers(); const result = await promise; expect(getFile).toHaveBeenCalledTimes(2); + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ + url: `https://api.telegram.org/file/bot${BOT_TOKEN}/voice/file_0.oga`, + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, + }), + ); return result; } diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 945cd2c25579..a20bf0456102 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -35,6 +35,9 @@ import type { StickerMetadata, TelegramContext } from "./types.js"; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; const FILE_TOO_BIG_RE = /file is too big/i; +const TELEGRAM_MEDIA_SSRF_POLICY = { + allowRfc2544BenchmarkRange: true, +} as const; export async function deliverReplies(params: { replies: ReplyPayload[]; @@ -320,6 +323,7 @@ export async function resolveMedia( fetchImpl, filePathHint: filePath, maxBytes, + ssrfPolicy: TELEGRAM_MEDIA_SSRF_POLICY, }); const originalName = fetched.fileName ?? filePath; return saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes, originalName); From ae281a6f61e3b4566ff79893506385389f81c9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E5=81=9A=E4=BA=86=E7=9D=A1=E5=A4=A7=E8=A7=89?= <64798754+stakeswky@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:33:17 +0800 Subject: [PATCH 026/408] fix: suppress "Run doctor --fix" hint when already in fix mode with no changes (#24666) When running `openclaw doctor --fix` and no config changes are needed, the else branch unconditionally showed "Run doctor --fix to apply changes" which is confusing since we just ran --fix. Now the hint only appears when NOT in fix mode (i.e. when running plain `openclaw doctor`). When in fix mode with nothing to change, the command silently proceeds to the "Doctor complete." outro. Fixes #24566 Co-authored-by: User --- src/commands/doctor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 714a3d2574f5..4aa0241da194 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -301,7 +301,7 @@ export async function doctorCommand( if (fs.existsSync(backupPath)) { runtime.log(`Backup: ${shortenHomePath(backupPath)}`); } - } else { + } else if (!prompter.shouldRepair) { runtime.log(`Run "${formatCliCommand("openclaw doctor --fix")}" to apply changes.`); } From 1e23d2ecea005daaff14ebe6b08fcdc56a75c406 Mon Sep 17 00:00:00 2001 From: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:33:21 -0400 Subject: [PATCH 027/408] fix(whatsapp): respect selfChatMode config in access-control (#24738) The selfChatMode config field was resolved by accounts.ts but never consumed in the access-control logic. Use nullish coalescing so an explicit true/false from config takes precedence over the allowFrom heuristic, while undefined falls back to the existing behavior. Fixes #23788 Co-authored-by: Claude --- src/web/inbound/access-control.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/inbound/access-control.ts b/src/web/inbound/access-control.ts index 794897a53885..2e759507cb9c 100644 --- a/src/web/inbound/access-control.ts +++ b/src/web/inbound/access-control.ts @@ -75,7 +75,7 @@ export async function checkInboundAccessControl(params: { account.groupAllowFrom ?? (configuredAllowFrom && configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined); const isSamePhone = params.from === params.selfE164; - const isSelfChat = isSelfChatMode(params.selfE164, configuredAllowFrom); + const isSelfChat = account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom); const pairingGraceMs = typeof params.pairingGraceMs === "number" && params.pairingGraceMs > 0 ? params.pairingGraceMs From a3b82a563dfbb963e552b54463dc0578060d1458 Mon Sep 17 00:00:00 2001 From: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:33:24 -0400 Subject: [PATCH 028/408] fix: resolve symlinks in pnpm/bun global install detection (#24744) Use tryRealpath() instead of path.resolve() when comparing expected package paths in detectGlobalInstallManagerForRoot(). path.resolve() only normalizes path strings without following symlinks, causing pnpm global installs to go undetected since pnpm symlinks node_modules entries into its .pnpm content-addressable store. Fixes #22768 Co-authored-by: Claude Opus 4.6 --- src/infra/update-global.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index a678934b4094..e85949f3cab1 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -84,7 +84,8 @@ export async function detectGlobalInstallManagerForRoot( const globalReal = await tryRealpath(globalRoot); for (const name of ALL_PACKAGE_NAMES) { const expected = path.join(globalReal, name); - if (path.resolve(expected) === path.resolve(pkgReal)) { + const expectedReal = await tryRealpath(expected); + if (path.resolve(expectedReal) === path.resolve(pkgReal)) { return manager; } } @@ -94,7 +95,8 @@ export async function detectGlobalInstallManagerForRoot( const bunGlobalReal = await tryRealpath(bunGlobalRoot); for (const name of ALL_PACKAGE_NAMES) { const bunExpected = path.join(bunGlobalReal, name); - if (path.resolve(bunExpected) === path.resolve(pkgReal)) { + const bunExpectedReal = await tryRealpath(bunExpected); + if (path.resolve(bunExpectedReal) === path.resolve(pkgReal)) { return "bun"; } } From 04bcabcbae66b6c322f192e1265d817b14c290cf Mon Sep 17 00:00:00 2001 From: junwon <153147718+junjunjunbong@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:33:27 +0900 Subject: [PATCH 029/408] fix(infra): handle Windows dev=0 in sameFileIdentity TOCTOU check (#24939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(infra): handle Windows dev=0 in sameFileIdentity TOCTOU check On Windows, `fs.lstatSync` (path-based) returns `dev: 0` while `fs.fstatSync` (fd-based) returns the real NTFS volume serial number. This mismatch caused `sameFileIdentity` to always fail, making `openVerifiedFileSync` reject every file — silently breaking all Control UI static file serving (HTTP 404). Fall back to ino-only comparison when either dev is 0 on Windows. ino remains unique within a single volume, so TOCTOU protection is preserved. Fixes #24692 * fix: format sameFileIdentity wrapping (#24939) --------- Co-authored-by: Peter Steinberger --- src/infra/safe-open-sync.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/infra/safe-open-sync.ts b/src/infra/safe-open-sync.ts index ac4638483b3e..f2dbdfb703b1 100644 --- a/src/infra/safe-open-sync.ts +++ b/src/infra/safe-open-sync.ts @@ -17,7 +17,12 @@ function isExpectedPathError(error: unknown): boolean { } export function sameFileIdentity(left: fs.Stats, right: fs.Stats): boolean { - return left.dev === right.dev && left.ino === right.ino; + // On Windows, lstatSync (by path) may return dev=0 while fstatSync (by fd) + // returns the real volume serial number. When either dev is 0, fall back to + // ino-only comparison which is still unique within a single volume. + const devMatch = + left.dev === right.dev || (process.platform === "win32" && (left.dev === 0 || right.dev === 0)); + return devMatch && left.ino === right.ino; } export function openVerifiedFileSync(params: { From 52ac7634dbb93c647144afcae8f2d6db3e855513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E9=9B=B2?= <137844255@qq.com> Date: Tue, 24 Feb 2026 11:33:30 +0800 Subject: [PATCH 030/408] fix: persist reasoningLevel 'off' instead of deleting it (#24406) (#24559) When a user runs /reasoning off, the session patch handler deleted the reasoningLevel field from the session entry. This caused get-reply-directives to treat reasoning as 'not explicitly set', which triggered resolveDefaultReasoningLevel() to re-enable reasoning for capable models (e.g. Claude Opus). The fix persists 'off' explicitly, matching how directive-handling.persist.ts already handles the inline /reasoning off command. Fixes #24406 Fixes #24411 Co-authored-by: echoVic --- src/gateway/sessions-patch.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 99e83a3bea00..d55cf2cf1a41 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -186,11 +186,9 @@ export async function applySessionsPatchToStore(params: { if (!normalized) { return invalid('invalid reasoningLevel (use "on"|"off"|"stream")'); } - if (normalized === "off") { - delete next.reasoningLevel; - } else { - next.reasoningLevel = normalized; - } + // Persist "off" explicitly so that resolveDefaultReasoningLevel() + // does not re-enable reasoning for capable models (#24406). + next.reasoningLevel = normalized; } } From e3da57d956a848c00aa07667632c1a76b0c9f42a Mon Sep 17 00:00:00 2001 From: banna-commits Date: Tue, 24 Feb 2026 04:33:34 +0100 Subject: [PATCH 031/408] fix: add exponential backoff to announce queue drain on failure (#24783) When the gateway rejects connections (e.g. scope-upgrade 'pairing required'), the announce queue drain loop would retry every ~1s indefinitely because the only delay was the fixed debounceMs (default 1000ms). This adds a consecutiveFailures counter with exponential backoff: 2s, 4s, 8s, 16s, 32s, 60s (capped). The counter resets on successful drain. The backoff is applied by shifting lastEnqueuedAt forward so that waitForQueueDebounce naturally delays the next attempt. Fixes #24777 Co-authored-by: Knut --- src/agents/subagent-announce-queue.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/agents/subagent-announce-queue.ts b/src/agents/subagent-announce-queue.ts index c81dd94b1d92..611541c186e7 100644 --- a/src/agents/subagent-announce-queue.ts +++ b/src/agents/subagent-announce-queue.ts @@ -48,6 +48,8 @@ type AnnounceQueueState = { droppedCount: number; summaryLines: string[]; send: (item: AnnounceQueueItem) => Promise; + /** Consecutive drain failures — drives exponential backoff on errors. */ + consecutiveFailures: number; }; const ANNOUNCE_QUEUES = new Map(); @@ -89,6 +91,7 @@ function getAnnounceQueue( droppedCount: 0, summaryLines: [], send, + consecutiveFailures: 0, }; applyQueueRuntimeSettings({ target: created, @@ -174,10 +177,16 @@ function scheduleAnnounceDrain(key: string) { break; } } + // Drain succeeded — reset failure counter. + queue.consecutiveFailures = 0; } catch (err) { - // Keep items in queue and retry after debounce; avoid hot-loop retries. - queue.lastEnqueuedAt = Date.now(); - defaultRuntime.error?.(`announce queue drain failed for ${key}: ${String(err)}`); + queue.consecutiveFailures++; + // Exponential backoff on consecutive failures: 2s, 4s, 8s, ... capped at 60s. + const errorBackoffMs = Math.min(1000 * Math.pow(2, queue.consecutiveFailures), 60_000); + queue.lastEnqueuedAt = Date.now() + errorBackoffMs - queue.debounceMs; + defaultRuntime.error?.( + `announce queue drain failed for ${key} (attempt ${queue.consecutiveFailures}, retry in ${Math.round(errorBackoffMs / 1000)}s): ${String(err)}`, + ); } finally { queue.draining = false; if (queue.items.length === 0 && queue.droppedCount === 0) { From c1fe688d40d57ce513d633d8a7682383ddc6fdef Mon Sep 17 00:00:00 2001 From: Sid Date: Tue, 24 Feb 2026 11:33:37 +0800 Subject: [PATCH 032/408] fix(gateway): safely extract text from content arrays in prompt builder (#24946) * fix(gateway): safely extract text from message content arrays in prompt builder When HistoryEntry.body is a content array (e.g. [{type:"text", text:"hello"}]) rather than a plain string, template literal interpolation produces "[object Object]" instead of the actual message text. This affects users whose session messages were stored with array content format. Add a safeBody helper that detects non-string body values and uses extractTextFromChatContent to extract the text, preventing the [object Object] serialization in both the current-message return path and the history formatting path. Fixes openclaw#24688 Co-authored-by: Cursor * fix: format gateway agent prompt helper (#24946) --------- Co-authored-by: Cursor Co-authored-by: Peter Steinberger --- src/gateway/agent-prompt.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/gateway/agent-prompt.ts b/src/gateway/agent-prompt.ts index 58e12bacd02e..5904726b927c 100644 --- a/src/gateway/agent-prompt.ts +++ b/src/gateway/agent-prompt.ts @@ -1,10 +1,23 @@ import { buildHistoryContextFromEntries, type HistoryEntry } from "../auto-reply/reply/history.js"; +import { extractTextFromChatContent } from "../shared/chat-content.js"; export type ConversationEntry = { role: "user" | "assistant" | "tool"; entry: HistoryEntry; }; +/** + * Coerce body to string. Handles cases where body is a content array + * (e.g. [{type:"text", text:"hello"}]) that would serialize as + * [object Object] if used directly in a template literal. + */ +function safeBody(body: unknown): string { + if (typeof body === "string") { + return body; + } + return extractTextFromChatContent(body) ?? ""; +} + export function buildAgentMessageFromConversationEntries(entries: ConversationEntry[]): string { if (entries.length === 0) { return ""; @@ -31,10 +44,10 @@ export function buildAgentMessageFromConversationEntries(entries: ConversationEn const historyEntries = entries.slice(0, currentIndex).map((e) => e.entry); if (historyEntries.length === 0) { - return currentEntry.body; + return safeBody(currentEntry.body); } - const formatEntry = (entry: HistoryEntry) => `${entry.sender}: ${entry.body}`; + const formatEntry = (entry: HistoryEntry) => `${entry.sender}: ${safeBody(entry.body)}`; return buildHistoryContextFromEntries({ entries: [...historyEntries, currentEntry], currentMessage: formatEntry(currentEntry), From 38da3f40cb6b5f1b404f16c5a79c8a547a3e48f0 Mon Sep 17 00:00:00 2001 From: Sid Date: Tue, 24 Feb 2026 11:33:40 +0800 Subject: [PATCH 033/408] fix(discord): suppress reasoning/thinking block payloads from delivery (#24969) Block payloads (info.kind === "block") contain reasoning/thinking content that should only be visible in the internal web UI. When streamMode is "partial", these blocks were being delivered to Discord as visible messages, leaking chain-of-thought to end users. Add an early return for block payloads in the deliver callback, consistent with the WhatsApp fix and Telegram's existing behavior. Fixes #24532 Co-authored-by: Cursor --- src/discord/monitor/message-handler.process.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 2d5b4058f6ef..1c41fef76ec0 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -557,6 +557,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) humanDelay: resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload, info) => { const isFinal = info.kind === "final"; + if (info.kind === "block") { + // Block payloads carry reasoning/thinking content that should not be + // delivered to external channels. Skip them regardless of streamMode. + return; + } if (draftStream && isFinal) { await flushDraft(); const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; From 9ced64054f2b1de5ee6b2189faaf778febcb2ed4 Mon Sep 17 00:00:00 2001 From: Peter Machona Date: Tue, 24 Feb 2026 03:33:44 +0000 Subject: [PATCH 034/408] fix(auth): classify missing OAuth scopes as auth failures (#24761) --- src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts | 4 ++++ src/agents/pi-embedded-helpers/errors.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index f4ae781e8c35..278c2d30bcb1 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -393,6 +393,10 @@ describe("classifyFailoverReason", () => { expect(classifyFailoverReason("invalid api key")).toBe("auth"); expect(classifyFailoverReason("no credentials found")).toBe("auth"); expect(classifyFailoverReason("no api key found")).toBe("auth"); + expect(classifyFailoverReason("You have insufficient permissions for this operation.")).toBe( + "auth", + ); + expect(classifyFailoverReason("Missing scopes: model.request")).toBe("auth"); expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit"); expect(classifyFailoverReason("resource has been exhausted")).toBe("rate_limit"); expect( diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 80ba2219868b..e0c7bf4c8017 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -655,6 +655,9 @@ const ERROR_PATTERNS = { "unauthorized", "forbidden", "access denied", + "insufficient permissions", + "insufficient permission", + /missing scopes?:/i, "expired", "token has expired", /\b401\b/, From f5cab29ec745d6ec806f6b44434bea5b6ee91e0c Mon Sep 17 00:00:00 2001 From: Sid Date: Tue, 24 Feb 2026 11:33:47 +0800 Subject: [PATCH 035/408] fix(synology-chat): deregister stale webhook route before re-registering on restart (#24971) When the Synology Chat plugin restarts (auto-restart or health monitor), startAccount is called again without calling the previous stop(). The HTTP route is still registered, so registerPluginHttpRoute returns a no-op unregister function and logs "already registered". This triggers another restart, creating an infinite loop. Store the unregister function at module level keyed by account+path. Before registering, check for and call any stale unregister from the previous start cycle, ensuring a clean slate for route registration. Fixes #24894 Co-authored-by: Cursor --- extensions/synology-chat/src/channel.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 0e205f60c3e8..37d4a4216ba9 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -20,6 +20,8 @@ import { createWebhookHandler } from "./webhook-handler.js"; const CHANNEL_ID = "synology-chat"; const SynologyChatConfigSchema = buildChannelConfigSchema(z.object({}).passthrough()); +const activeRouteUnregisters = new Map void>(); + export function createSynologyChatPlugin() { return { id: CHANNEL_ID, @@ -270,7 +272,16 @@ export function createSynologyChatPlugin() { log, }); - // Register HTTP route via the SDK + // Deregister any stale route from a previous start (e.g. on auto-restart) + // to avoid "already registered" collisions that trigger infinite loops. + const routeKey = `${accountId}:${account.webhookPath}`; + const prevUnregister = activeRouteUnregisters.get(routeKey); + if (prevUnregister) { + log?.info?.(`Deregistering stale route before re-registering: ${account.webhookPath}`); + prevUnregister(); + activeRouteUnregisters.delete(routeKey); + } + const unregister = registerPluginHttpRoute({ path: account.webhookPath, pluginId: CHANNEL_ID, @@ -278,6 +289,7 @@ export function createSynologyChatPlugin() { log: (msg: string) => log?.info?.(msg), handler, }); + activeRouteUnregisters.set(routeKey, unregister); log?.info?.(`Registered HTTP route: ${account.webhookPath} for Synology Chat`); @@ -285,6 +297,7 @@ export function createSynologyChatPlugin() { stop: () => { log?.info?.(`Stopping Synology Chat channel (account: ${accountId})`); if (typeof unregister === "function") unregister(); + activeRouteUnregisters.delete(routeKey); }, }; }, From bf91b347c1a2d49827ed79cf6e91248a07e6e909 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Tue, 24 Feb 2026 11:33:51 +0800 Subject: [PATCH 036/408] fix(plugins): use manifest id as config entry key instead of npm package name (#24796) * fix(plugins): use manifest id as config key instead of npm package name Plugin manifests (openclaw.plugin.json) define a canonical 'id' field that is used as the authoritative plugin identifier by the manifest registry. However, the install command was deriving the config entry key from the npm package name (e.g. 'cognee-openclaw') rather than the manifest id (e.g. 'memory-cognee'), causing a latent mismatch. On the next gateway reload the plugin could not be found under the config key derived from the npm package name, causing 'plugin not found' errors and potentially shutting the gateway down. Fix: after extracting the package directory, read openclaw.plugin.json and prefer its 'id' field over the npm package name when registering the config entry. Falls back to the npm-derived id if the manifest file is absent or has no valid id. A diagnostic info message is emitted when the two values differ so the mismatch is visible in the install log. The update path (src/plugins/update.ts) already correctly reads the manifest id and is unaffected. Fixes #24429 * fix: format plugin install manifest-id path (#24796) --------- Co-authored-by: Peter Steinberger --- src/plugins/install.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 40aeb3c5a637..49ce72dcd07f 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -26,6 +26,7 @@ import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js"; import * as skillScanner from "../security/skill-scanner.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +import { loadPluginManifest } from "./manifest.js"; type PluginInstallLogger = { info?: (message: string) => void; @@ -149,7 +150,17 @@ async function installPluginFromPackageDir(params: { } const pkgName = typeof manifest.name === "string" ? manifest.name : ""; - const pluginId = pkgName ? unscopedPackageName(pkgName) : "plugin"; + const npmPluginId = pkgName ? unscopedPackageName(pkgName) : "plugin"; + + // Prefer the canonical `id` from openclaw.plugin.json over the npm package name. + // This avoids a latent key-mismatch bug: if the manifest id (e.g. "memory-cognee") + // differs from the npm package name (e.g. "cognee-openclaw"), the plugin registry + // uses the manifest id as the authoritative key, so the config entry must match it. + const ocManifestResult = loadPluginManifest(params.packageDir); + const manifestPluginId = + ocManifestResult.ok && ocManifestResult.manifest.id ? ocManifestResult.manifest.id : undefined; + + const pluginId = manifestPluginId ?? npmPluginId; const pluginIdError = validatePluginId(pluginId); if (pluginIdError) { return { ok: false, error: pluginIdError }; @@ -161,6 +172,12 @@ async function installPluginFromPackageDir(params: { }; } + if (manifestPluginId && manifestPluginId !== npmPluginId) { + logger.info?.( + `Plugin manifest id "${manifestPluginId}" differs from npm package name "${npmPluginId}"; using manifest id as the config key.`, + ); + } + const packageDir = path.resolve(params.packageDir); const forcedScanEntries: string[] = []; for (const entry of extensions) { From d95ee859f88008ac7570c37a2eefd94d960e4a09 Mon Sep 17 00:00:00 2001 From: Sid Date: Tue, 24 Feb 2026 11:33:54 +0800 Subject: [PATCH 037/408] fix(cron): use full prompt mode for isolated cron sessions to include skills (#24944) Isolated cron sessions (agentTurn) were grouped with subagent sessions under the "minimal" prompt mode, which causes buildSkillsSection to return an empty array. This meant was never included in the system prompt for isolated cron runs. Subagent sessions legitimately need minimal prompts (reduced context), but isolated cron sessions are full agent turns that should have access to all configured skills, matching the behavior of normal chat sessions and non-isolated cron runs. Remove isCronSessionKey from the minimal prompt condition so only subagent sessions use "minimal" mode. Fixes openclaw#24888 Co-authored-by: Cursor --- src/agents/pi-embedded-runner/run/attempt.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index f811b2c4ff74..12d246e8a303 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -19,7 +19,7 @@ import type { PluginHookBeforeAgentStartResult, PluginHookBeforePromptBuildResult, } from "../../../plugins/types.js"; -import { isCronSessionKey, isSubagentSessionKey } from "../../../routing/session-key.js"; +import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js"; import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js"; import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js"; @@ -494,10 +494,7 @@ export async function runEmbeddedAttempt( }, }); const isDefaultAgent = sessionAgentId === defaultAgentId; - const promptMode = - isSubagentSessionKey(params.sessionKey) || isCronSessionKey(params.sessionKey) - ? "minimal" - : "full"; + const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full"; const docsPath = await resolveOpenClawDocsPath({ workspaceDir: effectiveWorkspace, argv1: process.argv[1], From 0bdcca2f350978843d5553fb26f0a753e908129c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:34:31 +0000 Subject: [PATCH 038/408] test(whatsapp): add log redaction coverage --- CHANGELOG.md | 1 + src/web/auto-reply/heartbeat-runner.test.ts | 36 ++++++++++++++++++--- src/web/outbound.test.ts | 30 +++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a7cc7560768..079ad76cb822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. - Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. - Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/src/web/auto-reply/heartbeat-runner.test.ts b/src/web/auto-reply/heartbeat-runner.test.ts index 78014787ad35..87d8d8a7ca94 100644 --- a/src/web/auto-reply/heartbeat-runner.test.ts +++ b/src/web/auto-reply/heartbeat-runner.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { getReplyFromConfig } from "../../auto-reply/reply.js"; import { HEARTBEAT_TOKEN } from "../../auto-reply/tokens.js"; +import { redactIdentifier } from "../../logging/redact-identifier.js"; import type { sendMessageWhatsApp } from "../outbound.js"; const state = vi.hoisted(() => ({ @@ -15,6 +16,10 @@ const state = vi.hoisted(() => ({ idleExpiresAt: null as number | null, }, events: [] as unknown[], + loggerInfoCalls: [] as unknown[][], + loggerWarnCalls: [] as unknown[][], + heartbeatInfoLogs: [] as string[], + heartbeatWarnLogs: [] as string[], })); vi.mock("../../agents/current-time.js", () => ({ @@ -64,15 +69,15 @@ vi.mock("../../infra/heartbeat-events.js", () => ({ vi.mock("../../logging.js", () => ({ getChildLogger: () => ({ - info: () => {}, - warn: () => {}, + info: (...args: unknown[]) => state.loggerInfoCalls.push(args), + warn: (...args: unknown[]) => state.loggerWarnCalls.push(args), }), })); vi.mock("./loggers.js", () => ({ whatsappHeartbeatLog: { - info: () => {}, - warn: () => {}, + info: (msg: string) => state.heartbeatInfoLogs.push(msg), + warn: (msg: string) => state.heartbeatWarnLogs.push(msg), }, })); @@ -115,6 +120,10 @@ describe("runWebHeartbeatOnce", () => { idleExpiresAt: null, }; state.events = []; + state.loggerInfoCalls = []; + state.loggerWarnCalls = []; + state.heartbeatInfoLogs = []; + state.heartbeatWarnLogs = []; senderMock = vi.fn(async () => ({ messageId: "m1" })); sender = senderMock as unknown as typeof sendMessageWhatsApp; @@ -187,4 +196,23 @@ describe("runWebHeartbeatOnce", () => { ]), ); }); + + it("redacts recipient and omits body preview in heartbeat logs", async () => { + replyResolverMock.mockResolvedValue({ text: "sensitive heartbeat body" }); + const { runWebHeartbeatOnce } = await getModules(); + await runWebHeartbeatOnce(buildRunArgs({ dryRun: true })); + + const expected = redactIdentifier("+123"); + const heartbeatLogs = state.heartbeatInfoLogs.join("\n"); + const childLoggerLogs = state.loggerInfoCalls.map((entry) => JSON.stringify(entry)).join("\n"); + + expect(heartbeatLogs).toContain(expected); + expect(heartbeatLogs).not.toContain("+123"); + expect(heartbeatLogs).not.toContain("sensitive heartbeat body"); + + expect(childLoggerLogs).toContain(expected); + expect(childLoggerLogs).not.toContain("+123"); + expect(childLoggerLogs).not.toContain("sensitive heartbeat body"); + expect(childLoggerLogs).not.toContain('"preview"'); + }); }); diff --git a/src/web/outbound.test.ts b/src/web/outbound.test.ts index 5f627b454ac5..e60d15158fc2 100644 --- a/src/web/outbound.test.ts +++ b/src/web/outbound.test.ts @@ -1,5 +1,10 @@ +import crypto from "node:crypto"; +import fsSync from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetLogger, setLoggerOverride } from "../logging.js"; +import { redactIdentifier } from "../logging/redact-identifier.js"; import { setActiveWebListener } from "./active-listener.js"; const loadWebMediaMock = vi.fn(); @@ -154,6 +159,31 @@ describe("web outbound", () => { }); }); + it("redacts recipients and poll text in outbound logs", async () => { + const logPath = path.join(os.tmpdir(), `openclaw-outbound-${crypto.randomUUID()}.log`); + setLoggerOverride({ level: "trace", file: logPath }); + + await sendPollWhatsApp( + "+1555", + { question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 1 }, + { verbose: false }, + ); + + await vi.waitFor( + () => { + expect(fsSync.existsSync(logPath)).toBe(true); + }, + { timeout: 2_000, interval: 5 }, + ); + + const content = fsSync.readFileSync(logPath, "utf-8"); + expect(content).toContain(redactIdentifier("+1555")); + expect(content).toContain(redactIdentifier("1555@s.whatsapp.net")); + expect(content).not.toContain(`"to":"+1555"`); + expect(content).not.toContain(`"jid":"1555@s.whatsapp.net"`); + expect(content).not.toContain("Lunch?"); + }); + it("sends reactions via active listener", async () => { await sendReactionWhatsApp("1555@s.whatsapp.net", "msg123", "✅", { verbose: false, From aef45b2abb681f85bf902c1596411cfbfb8159b5 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:38:40 -0800 Subject: [PATCH 039/408] fix(logging): redact phone numbers and message content from WhatsApp logs Apply redactIdentifier() (SHA-256 hashing) to all recipient JIDs and phone numbers logged by sendMessageWhatsApp, sendReactionWhatsApp, sendPollWhatsApp, and runWebHeartbeatOnce. Remove poll question text and message preview content from log entries, replacing with character counts where useful for debugging. The existing redactIdentifier() utility in src/logging/redact-identifier.ts was already implemented but not wired into any WhatsApp logging path. This commit connects it to all affected call sites while leaving functional parameters (actual send calls, event emitters) untouched. Closes #24957 --- src/web/auto-reply/heartbeat-runner.ts | 48 +++++++++++++++----------- src/web/outbound.ts | 43 ++++++++++++----------- 2 files changed, 51 insertions(+), 40 deletions(-) diff --git a/src/web/auto-reply/heartbeat-runner.ts b/src/web/auto-reply/heartbeat-runner.ts index 5b89c785c658..e393339a7810 100644 --- a/src/web/auto-reply/heartbeat-runner.ts +++ b/src/web/auto-reply/heartbeat-runner.ts @@ -18,13 +18,13 @@ import { import { emitHeartbeatEvent, resolveIndicatorType } from "../../infra/heartbeat-events.js"; import { resolveHeartbeatVisibility } from "../../infra/heartbeat-visibility.js"; import { getChildLogger } from "../../logging.js"; +import { redactIdentifier } from "../../logging/redact-identifier.js"; import { normalizeMainKey } from "../../routing/session-key.js"; import { sendMessageWhatsApp } from "../outbound.js"; import { newConnectionId } from "../reconnect.js"; import { formatError } from "../session.js"; import { whatsappHeartbeatLog } from "./loggers.js"; import { getSessionSnapshot } from "./session-snapshot.js"; -import { elide } from "./util.js"; export async function runWebHeartbeatOnce(opts: { cfg?: ReturnType; @@ -40,10 +40,11 @@ export async function runWebHeartbeatOnce(opts: { const replyResolver = opts.replyResolver ?? getReplyFromConfig; const sender = opts.sender ?? sendMessageWhatsApp; const runId = newConnectionId(); + const redactedTo = redactIdentifier(to); const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId, - to, + to: redactedTo, }); const cfg = cfgOverride ?? loadConfig(); @@ -57,20 +58,20 @@ export async function runWebHeartbeatOnce(opts: { return false; } if (dryRun) { - whatsappHeartbeatLog.info(`[dry-run] heartbeat ok -> ${to}`); + whatsappHeartbeatLog.info(`[dry-run] heartbeat ok -> ${redactedTo}`); return false; } const sendResult = await sender(to, heartbeatOkText, { verbose }); heartbeatLogger.info( { - to, + to: redactedTo, messageId: sendResult.messageId, chars: heartbeatOkText.length, reason: "heartbeat-ok", }, "heartbeat ok sent", ); - whatsappHeartbeatLog.info(`heartbeat ok sent to ${to} (id ${sendResult.messageId})`); + whatsappHeartbeatLog.info(`heartbeat ok sent to ${redactedTo} (id ${sendResult.messageId})`); return true; }; @@ -100,7 +101,7 @@ export async function runWebHeartbeatOnce(opts: { if (verbose) { heartbeatLogger.info( { - to, + to: redactedTo, sessionKey: sessionSnapshot.key, sessionId: sessionId ?? sessionSnapshot.entry?.sessionId ?? null, sessionFresh: sessionSnapshot.fresh, @@ -122,7 +123,7 @@ export async function runWebHeartbeatOnce(opts: { if (overrideBody) { if (dryRun) { whatsappHeartbeatLog.info( - `[dry-run] web send -> ${to}: ${elide(overrideBody.trim(), 200)} (manual message)`, + `[dry-run] web send -> ${redactedTo} (${overrideBody.trim().length} chars, manual message)`, ); return; } @@ -137,19 +138,21 @@ export async function runWebHeartbeatOnce(opts: { }); heartbeatLogger.info( { - to, + to: redactedTo, messageId: sendResult.messageId, chars: overrideBody.length, reason: "manual-message", }, "manual heartbeat message sent", ); - whatsappHeartbeatLog.info(`manual heartbeat sent to ${to} (id ${sendResult.messageId})`); + whatsappHeartbeatLog.info( + `manual heartbeat sent to ${redactedTo} (id ${sendResult.messageId})`, + ); return; } if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) { - heartbeatLogger.info({ to, reason: "alerts-disabled" }, "heartbeat skipped"); + heartbeatLogger.info({ to: redactedTo, reason: "alerts-disabled" }, "heartbeat skipped"); emitHeartbeatEvent({ status: "skipped", to, @@ -181,7 +184,7 @@ export async function runWebHeartbeatOnce(opts: { ) { heartbeatLogger.info( { - to, + to: redactedTo, reason: "empty-reply", sessionId: sessionSnapshot.entry?.sessionId ?? null, }, @@ -226,7 +229,7 @@ export async function runWebHeartbeatOnce(opts: { } heartbeatLogger.info( - { to, reason: "heartbeat-token", rawLength: replyPayload.text?.length }, + { to: redactedTo, reason: "heartbeat-token", rawLength: replyPayload.text?.length }, "heartbeat skipped", ); const okSent = await maybeSendHeartbeatOk(); @@ -241,14 +244,17 @@ export async function runWebHeartbeatOnce(opts: { } if (hasMedia) { - heartbeatLogger.warn({ to }, "heartbeat reply contained media; sending text only"); + heartbeatLogger.warn( + { to: redactedTo }, + "heartbeat reply contained media; sending text only", + ); } const finalText = stripped.text || replyPayload.text || ""; // Check if alerts are disabled for WhatsApp if (!visibility.showAlerts) { - heartbeatLogger.info({ to, reason: "alerts-disabled" }, "heartbeat skipped"); + heartbeatLogger.info({ to: redactedTo, reason: "alerts-disabled" }, "heartbeat skipped"); emitHeartbeatEvent({ status: "skipped", to, @@ -262,8 +268,11 @@ export async function runWebHeartbeatOnce(opts: { } if (dryRun) { - heartbeatLogger.info({ to, reason: "dry-run", chars: finalText.length }, "heartbeat dry-run"); - whatsappHeartbeatLog.info(`[dry-run] heartbeat -> ${to}: ${elide(finalText, 200)}`); + heartbeatLogger.info( + { to: redactedTo, reason: "dry-run", chars: finalText.length }, + "heartbeat dry-run", + ); + whatsappHeartbeatLog.info(`[dry-run] heartbeat -> ${redactedTo} (${finalText.length} chars)`); return; } @@ -278,17 +287,16 @@ export async function runWebHeartbeatOnce(opts: { }); heartbeatLogger.info( { - to, + to: redactedTo, messageId: sendResult.messageId, chars: finalText.length, - preview: elide(finalText, 140), }, "heartbeat sent", ); - whatsappHeartbeatLog.info(`heartbeat alert sent to ${to}`); + whatsappHeartbeatLog.info(`heartbeat alert sent to ${redactedTo}`); } catch (err) { const reason = formatError(err); - heartbeatLogger.warn({ to, error: reason }, "heartbeat failed"); + heartbeatLogger.warn({ to: redactedTo, error: reason }, "heartbeat failed"); whatsappHeartbeatLog.warn(`heartbeat failed (${reason})`); emitHeartbeatEvent({ status: "failed", diff --git a/src/web/outbound.ts b/src/web/outbound.ts index ce8b44669498..da1428a6980c 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -2,6 +2,7 @@ import { loadConfig } from "../config/config.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { generateSecureUuid } from "../infra/secure-random.js"; import { getChildLogger } from "../logging/logger.js"; +import { redactIdentifier } from "../logging/redact-identifier.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { convertMarkdownTables } from "../markdown/tables.js"; import { markdownToWhatsApp } from "../markdown/whatsapp.js"; @@ -37,13 +38,15 @@ export async function sendMessageWhatsApp( }); text = convertMarkdownTables(text ?? "", tableMode); text = markdownToWhatsApp(text); + const redactedTo = redactIdentifier(to); const logger = getChildLogger({ module: "web-outbound", correlationId, - to, + to: redactedTo, }); try { const jid = toWhatsappJid(to); + const redactedJid = redactIdentifier(jid); let mediaBuffer: Buffer | undefined; let mediaType: string | undefined; let documentFileName: string | undefined; @@ -69,8 +72,8 @@ export async function sendMessageWhatsApp( documentFileName = media.fileName; } } - outboundLog.info(`Sending message -> ${jid}${options.mediaUrl ? " (media)" : ""}`); - logger.info({ jid, hasMedia: Boolean(options.mediaUrl) }, "sending message"); + outboundLog.info(`Sending message -> ${redactedJid}${options.mediaUrl ? " (media)" : ""}`); + logger.info({ jid: redactedJid, hasMedia: Boolean(options.mediaUrl) }, "sending message"); await active.sendComposingTo(to); const hasExplicitAccountId = Boolean(options.accountId?.trim()); const accountId = hasExplicitAccountId ? resolvedAccountId : undefined; @@ -88,13 +91,13 @@ export async function sendMessageWhatsApp( const messageId = (result as { messageId?: string })?.messageId ?? "unknown"; const durationMs = Date.now() - startedAt; outboundLog.info( - `Sent message ${messageId} -> ${jid}${options.mediaUrl ? " (media)" : ""} (${durationMs}ms)`, + `Sent message ${messageId} -> ${redactedJid}${options.mediaUrl ? " (media)" : ""} (${durationMs}ms)`, ); - logger.info({ jid, messageId }, "sent message"); + logger.info({ jid: redactedJid, messageId }, "sent message"); return { messageId, toJid: jid }; } catch (err) { logger.error( - { err: String(err), to, hasMedia: Boolean(options.mediaUrl) }, + { err: String(err), to: redactedTo, hasMedia: Boolean(options.mediaUrl) }, "failed to send via web session", ); throw err; @@ -114,16 +117,18 @@ export async function sendReactionWhatsApp( ): Promise { const correlationId = generateSecureUuid(); const { listener: active } = requireActiveWebListener(options.accountId); + const redactedChatJid = redactIdentifier(chatJid); const logger = getChildLogger({ module: "web-outbound", correlationId, - chatJid, + chatJid: redactedChatJid, messageId, }); try { const jid = toWhatsappJid(chatJid); + const redactedJid = redactIdentifier(jid); outboundLog.info(`Sending reaction "${emoji}" -> message ${messageId}`); - logger.info({ chatJid: jid, messageId, emoji }, "sending reaction"); + logger.info({ chatJid: redactedJid, messageId, emoji }, "sending reaction"); await active.sendReaction( chatJid, messageId, @@ -132,10 +137,10 @@ export async function sendReactionWhatsApp( options.participant, ); outboundLog.info(`Sent reaction "${emoji}" -> message ${messageId}`); - logger.info({ chatJid: jid, messageId, emoji }, "sent reaction"); + logger.info({ chatJid: redactedJid, messageId, emoji }, "sent reaction"); } catch (err) { logger.error( - { err: String(err), chatJid, messageId, emoji }, + { err: String(err), chatJid: redactedChatJid, messageId, emoji }, "failed to send reaction via web session", ); throw err; @@ -150,19 +155,20 @@ export async function sendPollWhatsApp( const correlationId = generateSecureUuid(); const startedAt = Date.now(); const { listener: active } = requireActiveWebListener(options.accountId); + const redactedTo = redactIdentifier(to); const logger = getChildLogger({ module: "web-outbound", correlationId, - to, + to: redactedTo, }); try { const jid = toWhatsappJid(to); + const redactedJid = redactIdentifier(jid); const normalized = normalizePollInput(poll, { maxOptions: 12 }); - outboundLog.info(`Sending poll -> ${jid}: "${normalized.question}"`); + outboundLog.info(`Sending poll -> ${redactedJid}`); logger.info( { - jid, - question: normalized.question, + jid: redactedJid, optionCount: normalized.options.length, maxSelections: normalized.maxSelections, }, @@ -171,14 +177,11 @@ export async function sendPollWhatsApp( const result = await active.sendPoll(to, normalized); const messageId = (result as { messageId?: string })?.messageId ?? "unknown"; const durationMs = Date.now() - startedAt; - outboundLog.info(`Sent poll ${messageId} -> ${jid} (${durationMs}ms)`); - logger.info({ jid, messageId }, "sent poll"); + outboundLog.info(`Sent poll ${messageId} -> ${redactedJid} (${durationMs}ms)`); + logger.info({ jid: redactedJid, messageId }, "sent poll"); return { messageId, toJid: jid }; } catch (err) { - logger.error( - { err: String(err), to, question: poll.question }, - "failed to send poll via web session", - ); + logger.error({ err: String(err), to: redactedTo }, "failed to send poll via web session"); throw err; } } From 3a653082d8cc296b81e15fd295239c4feb9f7dd9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:39:25 +0000 Subject: [PATCH 040/408] fix(config): align whatsapp enabled schema with auto-enable --- CHANGELOG.md | 1 + src/config/config.schema-regressions.test.ts | 12 ++++++++++++ src/config/plugin-auto-enable.test.ts | 18 ++++++++++++++++++ src/config/types.whatsapp.ts | 2 ++ src/config/zod-schema.providers-whatsapp.ts | 1 + 5 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 079ad76cb822..99db9758816d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai - Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc. - Gateway/Restart: treat child listener PIDs as owned by the service runtime PID during restart health checks to avoid false stale-process kills and restart timeouts on launchd/systemd. (#24696) Thanks @gumadeiras. - Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn. +- Channels/WhatsApp: accept `channels.whatsapp.enabled` in config validation to match built-in channel auto-enable behavior, preventing `Unrecognized key: "enabled"` failures during channel setup. (#24263) - Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc. - Security/ACP: harden ACP client permission auto-approval to require trusted core tool IDs, ignore untrusted `toolCall.kind` hints, and scope `read` auto-approval to the active working directory so unknown tool names and out-of-scope file reads always prompt. This ships in the next npm release. Thanks @nedlir for reporting. - Security/Skills: escape user-controlled prompt, filename, and output-path values in `openai-image-gen` HTML gallery generation to prevent stored XSS in generated `index.html` output. (#12538) Thanks @CornBrother0x. diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index ff42403f8682..c183b34fa8e1 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -63,6 +63,18 @@ describe("config schema regressions", () => { expect(res.ok).toBe(true); }); + it("accepts channels.whatsapp.enabled", () => { + const res = validateConfigObject({ + channels: { + whatsapp: { + enabled: true, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + it("rejects unsafe iMessage remoteHost", () => { const res = validateConfigObject({ channels: { diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index 7f5779a18189..f3ef2961f4e5 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { validateConfigObject } from "./config.js"; import { applyPluginAutoEnable } from "./plugin-auto-enable.js"; describe("applyPluginAutoEnable", () => { @@ -48,6 +49,23 @@ describe("applyPluginAutoEnable", () => { expect(result.changes).toEqual([]); }); + it("keeps auto-enabled WhatsApp config schema-valid", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { + whatsapp: { + allowFrom: ["+15555550123"], + }, + }, + }, + env: {}, + }); + + expect(result.config.channels?.whatsapp?.enabled).toBe(true); + const validated = validateConfigObject(result.config); + expect(validated.ok).toBe(true); + }); + it("respects explicit disable", () => { const result = applyPluginAutoEnable({ config: { diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 6fa99ea7b845..395ce3b06b2a 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -36,6 +36,8 @@ export type WhatsAppAckReactionConfig = { }; type WhatsAppSharedConfig = { + /** Whether the WhatsApp channel is enabled. */ + enabled?: boolean; /** Direct message access policy (default: pairing). */ dmPolicy?: DmPolicy; /** Same-phone setup (bot uses your personal WhatsApp number). */ diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 92c6daeffc3e..4387ed1abb59 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -32,6 +32,7 @@ const WhatsAppAckReactionSchema = z .optional(); const WhatsAppSharedSchema = z.object({ + enabled: z.boolean().optional(), capabilities: z.array(z.string()).optional(), markdown: MarkdownConfigSchema, configWrites: z.boolean().optional(), From 3d22af692ce4c66203ce4682f0262a9101d0f650 Mon Sep 17 00:00:00 2001 From: SidQin-cyber Date: Tue, 24 Feb 2026 10:13:35 +0800 Subject: [PATCH 041/408] fix(whatsapp): suppress reasoning/thinking content from WhatsApp delivery The deliver callback in process-message.ts was forwarding all payload kinds (tool, block, final) to WhatsApp. Block payloads contain the model's reasoning/thinking content, which should only be visible in the internal web UI. This caused chain-of-thought to leak to end users as separate WhatsApp messages. Add an early return for non-final payloads so only the actual response is delivered to the WhatsApp channel, matching how Telegram already filters by info.kind === "final". Fixes #24954 Fixes #24605 Co-authored-by: Cursor --- src/web/auto-reply/monitor/process-message.ts | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index cf3b4d60554a..1c48d4141e9a 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -368,6 +368,12 @@ export async function processMessage(params: { } }, deliver: async (payload: ReplyPayload, info) => { + if (info.kind !== "final") { + // Only deliver final replies to external messaging channels (WhatsApp). + // Block (reasoning/thinking) and tool updates are meant for the internal + // web UI only; sending them here leaks chain-of-thought to end users. + return; + } await deliverWebReply({ replyResult: payload, msg: params.msg, @@ -377,30 +383,23 @@ export async function processMessage(params: { chunkMode, replyLogger: params.replyLogger, connectionId: params.connectionId, - // Tool + block updates are noisy; skip their log lines. - skipLog: info.kind !== "final", + skipLog: false, tableMode, }); didSendReply = true; - if (info.kind === "tool") { - params.rememberSentText(payload.text, {}); - return; - } - const shouldLog = info.kind === "final" && payload.text ? true : undefined; + const shouldLog = payload.text ? true : undefined; params.rememberSentText(payload.text, { combinedBody, combinedBodySessionKey: params.route.sessionKey, logVerboseMessage: shouldLog, }); - if (info.kind === "final") { - const fromDisplay = - params.msg.chatType === "group" ? conversationId : (params.msg.from ?? "unknown"); - const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length); - whatsappOutboundLog.info(`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`); - if (shouldLogVerbose()) { - const preview = payload.text != null ? elide(payload.text, 400) : ""; - whatsappOutboundLog.debug(`Reply body: ${preview}${hasMedia ? " (media)" : ""}`); - } + const fromDisplay = + params.msg.chatType === "group" ? conversationId : (params.msg.from ?? "unknown"); + const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length); + whatsappOutboundLog.info(`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`); + if (shouldLogVerbose()) { + const preview = payload.text != null ? elide(payload.text, 400) : ""; + whatsappOutboundLog.debug(`Reply body: ${preview}${hasMedia ? " (media)" : ""}`); } }, onError: (err, info) => { From b5881d9ef44432cc97bbcf94e082616432f49c6b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:46:38 +0000 Subject: [PATCH 042/408] fix: avoid WhatsApp silent turns with final-only delivery (#24962) (thanks @SidQin-cyber) --- CHANGELOG.md | 1 + .../process-message.inbound-contract.test.ts | 75 ++++++++++++++++++- src/web/auto-reply/monitor/process-message.ts | 7 +- 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99db9758816d..d7800cdfc654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. +- WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. - Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. - Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts index 0404ec431454..0acd4056fc92 100644 --- a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -9,6 +9,9 @@ let capturedDispatchParams: unknown; let sessionDir: string | undefined; let sessionStorePath: string; let backgroundTasks: Set>; +const { deliverWebReplyMock } = vi.hoisted(() => ({ + deliverWebReplyMock: vi.fn(async () => {}), +})); const defaultReplyLogger = { info: () => {}, @@ -24,6 +27,7 @@ function makeProcessMessageArgs(params: { cfg?: unknown; groupHistories?: Map>; groupHistory?: Array<{ sender: string; body: string }>; + rememberSentText?: (text: string | undefined, opts: unknown) => void; }) { return { // oxlint-disable-next-line typescript/no-explicit-any @@ -47,7 +51,8 @@ function makeProcessMessageArgs(params: { // oxlint-disable-next-line typescript/no-explicit-any replyLogger: defaultReplyLogger as any, backgroundTasks, - rememberSentText: (_text: string | undefined, _opts: unknown) => {}, + rememberSentText: + params.rememberSentText ?? ((_text: string | undefined, _opts: unknown) => {}), echoHas: () => false, echoForget: () => {}, buildCombinedEchoKey: () => "echo", @@ -75,6 +80,10 @@ vi.mock("./last-route.js", () => ({ updateLastRouteInBackground: vi.fn(), })); +vi.mock("../deliver-reply.js", () => ({ + deliverWebReply: deliverWebReplyMock, +})); + import { processMessage } from "./process-message.js"; describe("web processMessage inbound contract", () => { @@ -82,6 +91,7 @@ describe("web processMessage inbound contract", () => { capturedCtx = undefined; capturedDispatchParams = undefined; backgroundTasks = new Set(); + deliverWebReplyMock.mockClear(); sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-process-message-")); sessionStorePath = path.join(sessionDir, "sessions.json"); }); @@ -229,4 +239,67 @@ describe("web processMessage inbound contract", () => { expect(groupHistories.get("whatsapp:default:group:123@g.us") ?? []).toHaveLength(0); }); + + it("suppresses non-final WhatsApp payload delivery", async () => { + const rememberSentText = vi.fn(); + await processMessage( + makeProcessMessageArgs({ + routeSessionKey: "agent:main:whatsapp:direct:+1555", + groupHistoryKey: "+1555", + rememberSentText, + cfg: { + channels: { whatsapp: { blockStreaming: true } }, + messages: {}, + session: { store: sessionStorePath }, + } as unknown as ReturnType, + msg: { + id: "msg1", + from: "+1555", + to: "+2000", + chatType: "direct", + body: "hi", + }, + }), + ); + + // oxlint-disable-next-line typescript/no-explicit-any + const deliver = (capturedDispatchParams as any)?.dispatcherOptions?.deliver as + | ((payload: { text?: string }, info: { kind: "tool" | "block" | "final" }) => Promise) + | undefined; + expect(deliver).toBeTypeOf("function"); + + await deliver?.({ text: "tool payload" }, { kind: "tool" }); + await deliver?.({ text: "block payload" }, { kind: "block" }); + expect(deliverWebReplyMock).not.toHaveBeenCalled(); + expect(rememberSentText).not.toHaveBeenCalled(); + + await deliver?.({ text: "final payload" }, { kind: "final" }); + expect(deliverWebReplyMock).toHaveBeenCalledTimes(1); + expect(rememberSentText).toHaveBeenCalledTimes(1); + }); + + it("forces disableBlockStreaming for WhatsApp dispatch", async () => { + await processMessage( + makeProcessMessageArgs({ + routeSessionKey: "agent:main:whatsapp:direct:+1555", + groupHistoryKey: "+1555", + cfg: { + channels: { whatsapp: { blockStreaming: true } }, + messages: {}, + session: { store: sessionStorePath }, + } as unknown as ReturnType, + msg: { + id: "msg1", + from: "+1555", + to: "+2000", + chatType: "direct", + body: "hi", + }, + }), + ); + + // oxlint-disable-next-line typescript/no-explicit-any + const replyOptions = (capturedDispatchParams as any)?.replyOptions; + expect(replyOptions?.disableBlockStreaming).toBe(true); + }); }); diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index 1c48d4141e9a..15607a4524ea 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -416,10 +416,9 @@ export async function processMessage(params: { onReplyStart: params.msg.sendComposing, }, replyOptions: { - disableBlockStreaming: - typeof params.cfg.channels?.whatsapp?.blockStreaming === "boolean" - ? !params.cfg.channels.whatsapp.blockStreaming - : undefined, + // WhatsApp delivery intentionally suppresses non-final payloads. + // Keep block streaming disabled so final replies are still produced. + disableBlockStreaming: true, onModelSelected, }, }); From 1565d7e7b3d1b0863308a96c97dcd897094e89fd Mon Sep 17 00:00:00 2001 From: Glucksberg Date: Mon, 23 Feb 2026 02:14:49 +0000 Subject: [PATCH 043/408] fix: increase verification max_tokens to 1024 for Poe API compatibility Poe API's Extended Thinking models (e.g. claude-sonnet-4.6) require budget_tokens >= 1024. The previous values (5 for OpenAI, 16 for Anthropic) caused HTTP 400 errors during provider verification. Fixes #23433 Co-Authored-By: Claude Opus 4.6 --- src/commands/onboard-custom.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index aff71ce7f3dd..a00471701b2c 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -303,7 +303,7 @@ async function requestOpenAiVerification(params: { body: { model: params.modelId, messages: [{ role: "user", content: "Hi" }], - max_tokens: 5, + max_tokens: 1024, }, }); } @@ -329,7 +329,7 @@ async function requestAnthropicVerification(params: { headers: buildAnthropicHeaders(params.apiKey), body: { model: params.modelId, - max_tokens: 16, + max_tokens: 1024, messages: [{ role: "user", content: "Hi" }], }, }); From 9cc7450edf5569d069c22134999270e2ed76301d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:48:44 +0000 Subject: [PATCH 044/408] docs(changelog): add missing unreleased fixes and reorder --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7800cdfc654..ab56cece4cb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,26 +10,44 @@ Docs: https://docs.openclaw.ai ### Fixes +- Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) +- Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) +- Sessions/Reasoning: persist `reasoningLevel: "off"` explicitly instead of deleting it so session overrides survive patch/update flows. (#24406, #24559) +- Plugins/Config: use plugin manifest `id` (instead of npm package name) for config entry keys so plugin settings stay bound correctly. (#24796) +- Synology Chat/Webhooks: deregister stale webhook routes before re-registering on channel restart to prevent duplicate route handling. (#24971) +- Gateway/Prompt builder: safely extract text from mixed content arrays when assembling prompts to avoid malformed prompt payloads. (#24946) +- WhatsApp/Access control: honor `selfChatMode` in inbound access-control checks. (#24738) +- Slack/Restart sentinel: map `threadId` to `replyToId` for restart sentinel notifications. (#24885) +- Gateway/Slug generation: respect agent-level model config in slug generation flows. (#24776) +- Agents/Workspace paths: strip null bytes and guard undefined `.trim()` calls for workspace-path handling to avoid `ENOTDIR`/`TypeError` crashes. (#24876, #24875) +- Auth/OAuth: classify missing OAuth scopes as auth failures for clearer remediation and retry behavior. (#24761) +- Doctor/UX: suppress the redundant "Run doctor --fix" hint when already in fix mode with no changes. (#24666) +- Doctor/Nix: skip false-positive permission warnings for Nix store symlinks in state-integrity checks. (#24901) +- Update/Systemd: back up an existing systemd unit before overwriting it during update flows. (#24350, #24937) +- Install/Global detection: resolve symlinks when detecting pnpm/bun global install paths. (#24744) +- Infra/Windows TOCTOU: handle Windows `dev=0` edge cases in same-file identity checks. (#24939) +- Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) +- Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) +- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. - WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. - Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. -- Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec approvals: restore two-phase approval registration + wait-decision handling for gateway/node exec paths, requiring approval IDs to be registered before returning `approval-pending` and honoring server-assigned approval IDs during wait resolution to prevent orphaned `/approve` flows and immediate-return races (`ask:on-miss`). This ships in the next npm release. Thanks @vitalyis for reporting. +- Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. -- Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. -- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. -- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. +- Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads. +- Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. - Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution. -- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting. - Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings. +- Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: restore two-phase approval registration + wait-decision handling for gateway/node exec paths, requiring approval IDs to be registered before returning `approval-pending` and honoring server-assigned approval IDs during wait resolution to prevent orphaned `/approve` flows and immediate-return races (`ask:on-miss`). This ships in the next npm release. Thanks @vitalyis for reporting. - Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: recognize `busybox`/`toybox` shell applets in wrapper analysis and allow-always persistence, persist inner executables instead of multiplexer wrapper binaries, and fail closed when multiplexer unwrapping is unsafe to prevent allow-always bypasses. This ships in the next npm release. Thanks @jiseoung for reporting. -- Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads. +- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting. ## 2026.2.23 (Unreleased) From 947883d2e00f55b83ce03e278ed2ed8736c7ff24 Mon Sep 17 00:00:00 2001 From: Glucksberg Date: Mon, 23 Feb 2026 02:29:22 +0000 Subject: [PATCH 045/408] fix: suppress sessions_send error warnings from leaking to chat (#23989) sessions_send timeout/error results were being surfaced as raw warning messages in Telegram chats because the tool is classified as mutating, which forces error warnings to always be shown. However, sessions_send failures are transient inter-session communication issues where the message may still have been delivered, so they should not leak to users. Co-Authored-By: Claude Opus 4.6 --- src/agents/pi-embedded-runner/run/payloads.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index f1ff4dda724f..7b3d40c5d009 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -67,6 +67,12 @@ function resolveToolErrorWarningPolicy(params: { if ((normalizedToolName === "exec" || normalizedToolName === "bash") && !includeDetails) { return { showWarning: false, includeDetails }; } + // sessions_send timeouts and errors are transient inter-session communication + // issues — the message may still have been delivered. Suppress warnings to + // prevent raw error text from leaking into the chat surface (#23989). + if (normalizedToolName === "sessions_send") { + return { showWarning: false, includeDetails }; + } const isMutatingToolError = params.lastToolError.mutatingAction ?? isLikelyMutatingToolName(params.lastToolError.toolName); if (isMutatingToolError) { From dd145f1346c944e7b2df22283bd470afd971adaf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:48:56 +0000 Subject: [PATCH 046/408] fix: suppress sessions_send warning leakage coverage (#24740) (thanks @Glucksberg) --- CHANGELOG.md | 1 + .../pi-embedded-runner/run/payloads.test.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab56cece4cb2..d283cb74fd4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) - Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. +- Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg. - WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. - WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. - Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. diff --git a/src/agents/pi-embedded-runner/run/payloads.test.ts b/src/agents/pi-embedded-runner/run/payloads.test.ts index 5d950f2ee10b..ee8acd1d43e6 100644 --- a/src/agents/pi-embedded-runner/run/payloads.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.test.ts @@ -60,4 +60,26 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => { absentDetail, }); }); + + it("suppresses sessions_send errors to avoid leaking transient relay failures", () => { + const payloads = buildPayloads({ + lastToolError: { toolName: "sessions_send", error: "delivery timeout" }, + verboseLevel: "on", + }); + + expect(payloads).toHaveLength(0); + }); + + it("suppresses sessions_send errors even when marked mutating", () => { + const payloads = buildPayloads({ + lastToolError: { + toolName: "sessions_send", + error: "delivery timeout", + mutatingAction: true, + }, + verboseLevel: "on", + }); + + expect(payloads).toHaveLength(0); + }); }); From 9f4764cd417e11bc3167c2e8a6817b40e3c40ec7 Mon Sep 17 00:00:00 2001 From: pandego <7780875+pandego@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:28:34 +0100 Subject: [PATCH 047/408] fix(plugins): guard legacy zod schemas without toJSONSchema --- src/channels/plugins/config-schema.test.ts | 17 +++++++++++++++ src/channels/plugins/config-schema.ts | 24 ++++++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 src/channels/plugins/config-schema.test.ts diff --git a/src/channels/plugins/config-schema.test.ts b/src/channels/plugins/config-schema.test.ts new file mode 100644 index 000000000000..2abd11e53af6 --- /dev/null +++ b/src/channels/plugins/config-schema.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { buildChannelConfigSchema } from "./config-schema.js"; + +describe("buildChannelConfigSchema", () => { + it("builds json schema when toJSONSchema is available", () => { + const schema = z.object({ enabled: z.boolean().default(true) }); + const result = buildChannelConfigSchema(schema); + expect(result.schema).toMatchObject({ type: "object" }); + }); + + it("falls back when toJSONSchema is missing (zod v3 plugin compatibility)", () => { + const legacySchema = {} as unknown as Parameters[0]; + const result = buildChannelConfigSchema(legacySchema); + expect(result.schema).toEqual({ type: "object", additionalProperties: true }); + }); +}); diff --git a/src/channels/plugins/config-schema.ts b/src/channels/plugins/config-schema.ts index 50b81e83b92d..75074ae569d9 100644 --- a/src/channels/plugins/config-schema.ts +++ b/src/channels/plugins/config-schema.ts @@ -1,11 +1,27 @@ import type { ZodTypeAny } from "zod"; import type { ChannelConfigSchema } from "./types.plugin.js"; +type ZodSchemaWithToJsonSchema = ZodTypeAny & { + toJSONSchema?: (params?: Record) => unknown; +}; + export function buildChannelConfigSchema(schema: ZodTypeAny): ChannelConfigSchema { + const schemaWithJson = schema as ZodSchemaWithToJsonSchema; + if (typeof schemaWithJson.toJSONSchema === "function") { + return { + schema: schemaWithJson.toJSONSchema({ + target: "draft-07", + unrepresentable: "any", + }) as Record, + }; + } + + // Compatibility fallback for plugins built against Zod v3 schemas, + // where `.toJSONSchema()` is unavailable. return { - schema: schema.toJSONSchema({ - target: "draft-07", - unrepresentable: "any", - }) as Record, + schema: { + type: "object", + additionalProperties: true, + }, }; } From 7a42558a3e3c1d29bc35a35159aee1f7566eb73f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:50:27 +0000 Subject: [PATCH 048/408] fix: harden legacy plugin schema compatibility tests (#24933) (thanks @pandego) --- CHANGELOG.md | 1 + src/channels/plugins/config-schema.test.ts | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d283cb74fd4d..dc2387313493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. - Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) - Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) - Sessions/Reasoning: persist `reasoningLevel: "off"` explicitly instead of deleting it so session overrides survive patch/update flows. (#24406, #24559) diff --git a/src/channels/plugins/config-schema.test.ts b/src/channels/plugins/config-schema.test.ts index 2abd11e53af6..93d65d728a56 100644 --- a/src/channels/plugins/config-schema.test.ts +++ b/src/channels/plugins/config-schema.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { buildChannelConfigSchema } from "./config-schema.js"; @@ -14,4 +14,23 @@ describe("buildChannelConfigSchema", () => { const result = buildChannelConfigSchema(legacySchema); expect(result.schema).toEqual({ type: "object", additionalProperties: true }); }); + + it("passes draft-07 compatibility options to toJSONSchema", () => { + const toJSONSchema = vi.fn(() => ({ + type: "object", + properties: { enabled: { type: "boolean" } }, + })); + const schema = { toJSONSchema } as unknown as Parameters[0]; + + const result = buildChannelConfigSchema(schema); + + expect(toJSONSchema).toHaveBeenCalledWith({ + target: "draft-07", + unrepresentable: "any", + }); + expect(result.schema).toEqual({ + type: "object", + properties: { enabled: { type: "boolean" } }, + }); + }); }); From 053b0df7d431abf45dd4b9615ddbbc4731ae33ad Mon Sep 17 00:00:00 2001 From: chilu18 Date: Mon, 23 Feb 2026 20:36:15 +0000 Subject: [PATCH 049/408] fix(ui): load saved locale on startup --- ui/src/i18n/lib/translate.ts | 37 +++++++++++++++++++----------- ui/src/i18n/test/translate.test.ts | 16 +++++++++++-- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/ui/src/i18n/lib/translate.ts b/ui/src/i18n/lib/translate.ts index 3b1cfa0978a3..0a03226ff420 100644 --- a/ui/src/i18n/lib/translate.ts +++ b/ui/src/i18n/lib/translate.ts @@ -18,20 +18,30 @@ class I18nManager { this.loadLocale(); } - private loadLocale() { + private resolveInitialLocale(): Locale { const saved = localStorage.getItem("openclaw.i18n.locale"); if (isSupportedLocale(saved)) { - this.locale = saved; - } else { - const navLang = navigator.language; - if (navLang.startsWith("zh")) { - this.locale = navLang === "zh-TW" || navLang === "zh-HK" ? "zh-TW" : "zh-CN"; - } else if (navLang.startsWith("pt")) { - this.locale = "pt-BR"; - } else { - this.locale = "en"; - } + return saved; + } + const navLang = navigator.language; + if (navLang.startsWith("zh")) { + return navLang === "zh-TW" || navLang === "zh-HK" ? "zh-TW" : "zh-CN"; + } + if (navLang.startsWith("pt")) { + return "pt-BR"; + } + return "en"; + } + + private loadLocale() { + const initialLocale = this.resolveInitialLocale(); + if (initialLocale === "en") { + this.locale = "en"; + return; } + // Use the normal locale setter so startup locale loading follows the same + // translation-loading + notify path as manual locale changes. + void this.setLocale(initialLocale); } public getLocale(): Locale { @@ -39,12 +49,13 @@ class I18nManager { } public async setLocale(locale: Locale) { - if (this.locale === locale) { + const needsTranslationLoad = !this.translations[locale]; + if (this.locale === locale && !needsTranslationLoad) { return; } // Lazy load translations if needed - if (!this.translations[locale]) { + if (needsTranslationLoad) { try { let module: Record; if (locale === "zh-CN") { diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts index 8d6f32ef2d67..b06aa8d2d232 100644 --- a/ui/src/i18n/test/translate.test.ts +++ b/ui/src/i18n/test/translate.test.ts @@ -2,10 +2,10 @@ import { describe, it, expect, beforeEach } from "vitest"; import { i18n, t } from "../lib/translate.ts"; describe("i18n", () => { - beforeEach(() => { + beforeEach(async () => { localStorage.clear(); // Reset to English - void i18n.setLocale("en"); + await i18n.setLocale("en"); }); it("should return the key if translation is missing", () => { @@ -28,4 +28,16 @@ describe("i18n", () => { // but let's assume it falls back to English for now. expect(t("common.health")).toBeDefined(); }); + + it("loads translations even when setting the same locale again", async () => { + const internal = 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("健康状况"); + }); }); From fd24b354498db5cf8b74606949dd564bf9a77f83 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:51:31 +0000 Subject: [PATCH 050/408] fix: cover startup locale hydration path (#24795) (thanks @chilu18) --- CHANGELOG.md | 1 + ui/src/i18n/test/translate.test.ts | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2387313493..fe7ba34ffb59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. - Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. - Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) - Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts index b06aa8d2d232..178fd12b1e3b 100644 --- a/ui/src/i18n/test/translate.test.ts +++ b/ui/src/i18n/test/translate.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { i18n, t } from "../lib/translate.ts"; describe("i18n", () => { @@ -40,4 +40,17 @@ describe("i18n", () => { await i18n.setLocale("zh-CN"); expect(t("common.health")).toBe("健康状况"); }); + + it("loads saved non-English locale on startup", async () => { + localStorage.setItem("openclaw.i18n.locale", "zh-CN"); + vi.resetModules(); + const fresh = await import("../lib/translate.ts"); + + for (let index = 0; index < 5 && fresh.i18n.getLocale() !== "zh-CN"; index += 1) { + await Promise.resolve(); + } + + expect(fresh.i18n.getLocale()).toBe("zh-CN"); + expect(fresh.t("common.health")).toBe("健康状况"); + }); }); From d883ecade638c2c05454a59e77beeba3432b4510 Mon Sep 17 00:00:00 2001 From: Zongxin Yang Date: Mon, 23 Feb 2026 19:20:06 -0500 Subject: [PATCH 051/408] fix(discord): fallback thread parent lookup when parentId missing --- .../monitor/threading.parent-info.test.ts | 47 +++++++++++++++++++ src/discord/monitor/threading.ts | 6 ++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/discord/monitor/threading.parent-info.test.ts diff --git a/src/discord/monitor/threading.parent-info.test.ts b/src/discord/monitor/threading.parent-info.test.ts new file mode 100644 index 000000000000..8ad36c11f94c --- /dev/null +++ b/src/discord/monitor/threading.parent-info.test.ts @@ -0,0 +1,47 @@ +import { ChannelType } from "@buape/carbon"; +import { describe, expect, it, vi } from "vitest"; +import { resolveDiscordThreadParentInfo } from "./threading.js"; + +describe("resolveDiscordThreadParentInfo", () => { + it("falls back to fetched thread parentId when parentId is missing in payload", async () => { + const fetchChannel = vi.fn(async (channelId: string) => { + if (channelId === "thread-1") { + return { + id: "thread-1", + type: ChannelType.PublicThread, + name: "thread-name", + parentId: "parent-1", + }; + } + if (channelId === "parent-1") { + return { + id: "parent-1", + type: ChannelType.GuildText, + name: "parent-name", + }; + } + return null; + }); + + const client = { + fetchChannel, + } as unknown as import("@buape/carbon").Client; + + const result = await resolveDiscordThreadParentInfo({ + client, + threadChannel: { + id: "thread-1", + parentId: undefined, + }, + channelInfo: null, + }); + + expect(fetchChannel).toHaveBeenCalledWith("thread-1"); + expect(fetchChannel).toHaveBeenCalledWith("parent-1"); + expect(result).toEqual({ + id: "parent-1", + name: "parent-name", + type: ChannelType.GuildText, + }); + }); +}); diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index 4efc83d0c74e..877329c29952 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -131,8 +131,12 @@ export async function resolveDiscordThreadParentInfo(params: { channelInfo: import("./message-utils.js").DiscordChannelInfo | null; }): Promise { const { threadChannel, channelInfo, client } = params; - const parentId = + let parentId = threadChannel.parentId ?? threadChannel.parent?.id ?? channelInfo?.parentId ?? undefined; + if (!parentId && threadChannel.id) { + const threadInfo = await resolveDiscordChannelInfo(client, threadChannel.id); + parentId = threadInfo?.parentId ?? undefined; + } if (!parentId) { return {}; } From a216f2dabe77185f197d8e2f71f07705bcf024b8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:52:27 +0000 Subject: [PATCH 052/408] fix: extend discord thread parent fallback coverage (#24897) (thanks @z-x-yang) --- CHANGELOG.md | 1 + .../monitor/threading.parent-info.test.ts | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe7ba34ffb59..d7851591e0f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. - Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. - Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. - Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) diff --git a/src/discord/monitor/threading.parent-info.test.ts b/src/discord/monitor/threading.parent-info.test.ts index 8ad36c11f94c..1954dd4fe9d6 100644 --- a/src/discord/monitor/threading.parent-info.test.ts +++ b/src/discord/monitor/threading.parent-info.test.ts @@ -44,4 +44,63 @@ describe("resolveDiscordThreadParentInfo", () => { type: ChannelType.GuildText, }); }); + + it("does not fetch thread info when parentId is already present", async () => { + const fetchChannel = vi.fn(async (channelId: string) => { + if (channelId === "parent-1") { + return { + id: "parent-1", + type: ChannelType.GuildText, + name: "parent-name", + }; + } + return null; + }); + + const client = { fetchChannel } as unknown as import("@buape/carbon").Client; + const result = await resolveDiscordThreadParentInfo({ + client, + threadChannel: { + id: "thread-1", + parentId: "parent-1", + }, + channelInfo: null, + }); + + expect(fetchChannel).toHaveBeenCalledTimes(1); + expect(fetchChannel).toHaveBeenCalledWith("parent-1"); + expect(result).toEqual({ + id: "parent-1", + name: "parent-name", + type: ChannelType.GuildText, + }); + }); + + it("returns empty parent info when fallback thread lookup has no parentId", async () => { + const fetchChannel = vi.fn(async (channelId: string) => { + if (channelId === "thread-1") { + return { + id: "thread-1", + type: ChannelType.PublicThread, + name: "thread-name", + parentId: undefined, + }; + } + return null; + }); + + const client = { fetchChannel } as unknown as import("@buape/carbon").Client; + const result = await resolveDiscordThreadParentInfo({ + client, + threadChannel: { + id: "thread-1", + parentId: undefined, + }, + channelInfo: null, + }); + + expect(fetchChannel).toHaveBeenCalledTimes(1); + expect(fetchChannel).toHaveBeenCalledWith("thread-1"); + expect(result).toEqual({}); + }); }); From 6c1ed9493c6086aec1c5223f82973358b0fb1e97 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:52:31 +0000 Subject: [PATCH 053/408] fix: harden queue retry debounce and add regression tests --- extensions/synology-chat/src/channel.test.ts | 35 +++++++++++++ .../pi-embedded-runner/run/attempt.test.ts | 17 ++++++- src/agents/pi-embedded-runner/run/attempt.ts | 9 +++- src/agents/subagent-announce-queue.test.ts | 49 +++++++++++++++++++ src/agents/subagent-announce-queue.ts | 8 +-- .../monitor/message-handler.process.test.ts | 20 +++++++- src/gateway/agent-prompt.test.ts | 49 +++++++++++++++++++ src/gateway/sessions-patch.test.ts | 32 ++++++++++++ src/plugins/install.test.ts | 44 +++++++++++++++++ 9 files changed, 257 insertions(+), 6 deletions(-) diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 622c7bffaedf..076339c4456d 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -39,6 +39,7 @@ vi.mock("zod", () => ({ })); const { createSynologyChatPlugin } = await import("./channel.js"); +const { registerPluginHttpRoute } = await import("openclaw/plugin-sdk"); describe("createSynologyChatPlugin", () => { it("returns a plugin object with all required sections", () => { @@ -336,5 +337,39 @@ describe("createSynologyChatPlugin", () => { const result = await plugin.gateway.startAccount(ctx); expect(typeof result.stop).toBe("function"); }); + + it("deregisters stale route before re-registering same account/path", async () => { + const unregisterFirst = vi.fn(); + const unregisterSecond = vi.fn(); + const registerMock = vi.mocked(registerPluginHttpRoute); + registerMock.mockReturnValueOnce(unregisterFirst).mockReturnValueOnce(unregisterSecond); + + const plugin = createSynologyChatPlugin(); + const ctx = { + cfg: { + channels: { + "synology-chat": { + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + webhookPath: "/webhook/synology", + }, + }, + }, + accountId: "default", + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + + const first = await plugin.gateway.startAccount(ctx); + const second = await plugin.gateway.startAccount(ctx); + + expect(registerMock).toHaveBeenCalledTimes(2); + expect(unregisterFirst).toHaveBeenCalledTimes(1); + expect(unregisterSecond).not.toHaveBeenCalled(); + + // Clean up active route map so this module-level state doesn't leak across tests. + first.stop(); + second.stop(); + }); }); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 8dcd25a415aa..ab25ce57e86d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -1,7 +1,11 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; -import { injectHistoryImagesIntoMessages, resolvePromptBuildHookResult } from "./attempt.js"; +import { + injectHistoryImagesIntoMessages, + resolvePromptBuildHookResult, + resolvePromptModeForSession, +} from "./attempt.js"; describe("injectHistoryImagesIntoMessages", () => { const image: ImageContent = { type: "image", data: "abc", mimeType: "image/png" }; @@ -103,3 +107,14 @@ describe("resolvePromptBuildHookResult", () => { expect(result.prependContext).toBe("from-hook"); }); }); + +describe("resolvePromptModeForSession", () => { + it("uses minimal mode for subagent sessions", () => { + expect(resolvePromptModeForSession("agent:main:subagent:child")).toBe("minimal"); + }); + + it("uses full mode for cron sessions", () => { + expect(resolvePromptModeForSession("agent:main:cron:job-1")).toBe("full"); + expect(resolvePromptModeForSession("agent:main:cron:job-1:run:run-abc")).toBe("full"); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 12d246e8a303..9406afae943b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -221,6 +221,13 @@ export async function resolvePromptBuildHookResult(params: { }; } +export function resolvePromptModeForSession(sessionKey?: string): "minimal" | "full" { + if (!sessionKey) { + return "full"; + } + return isSubagentSessionKey(sessionKey) ? "minimal" : "full"; +} + function summarizeMessagePayload(msg: AgentMessage): { textChars: number; imageBlocks: number } { const content = (msg as { content?: unknown }).content; if (typeof content === "string") { @@ -494,7 +501,7 @@ export async function runEmbeddedAttempt( }, }); const isDefaultAgent = sessionAgentId === defaultAgentId; - const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full"; + const promptMode = resolvePromptModeForSession(params.sessionKey); const docsPath = await resolveOpenClawDocsPath({ workspaceDir: effectiveWorkspace, argv1: process.argv[1], diff --git a/src/agents/subagent-announce-queue.test.ts b/src/agents/subagent-announce-queue.test.ts index 6e673cd2fda2..b638b2fad3f9 100644 --- a/src/agents/subagent-announce-queue.test.ts +++ b/src/agents/subagent-announce-queue.test.ts @@ -27,6 +27,7 @@ function createRetryingSend() { describe("subagent-announce-queue", () => { afterEach(() => { + vi.useRealTimers(); resetAnnounceQueuesForTests(); }); @@ -116,4 +117,52 @@ describe("subagent-announce-queue", () => { expect(sender.prompts[1]).toContain("Queued #2"); expect(sender.prompts[1]).toContain("queued item two"); }); + + it("uses debounce floor for retries when debounce exceeds backoff", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const previousFast = process.env.OPENCLAW_TEST_FAST; + delete process.env.OPENCLAW_TEST_FAST; + + try { + const attempts: number[] = []; + const send = vi.fn(async () => { + attempts.push(Date.now()); + if (attempts.length === 1) { + throw new Error("transient timeout"); + } + }); + + enqueueAnnounce({ + key: "announce:test:retry-debounce-floor", + item: { + prompt: "subagent completed", + enqueuedAt: Date.now(), + sessionKey: "agent:main:telegram:dm:u1", + }, + settings: { mode: "followup", debounceMs: 5_000 }, + send, + }); + + await vi.advanceTimersByTimeAsync(5_000); + expect(send).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(4_999); + expect(send).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + expect(send).toHaveBeenCalledTimes(2); + const [firstAttempt, secondAttempt] = attempts; + if (firstAttempt === undefined || secondAttempt === undefined) { + throw new Error("expected two retry attempts"); + } + expect(secondAttempt - firstAttempt).toBeGreaterThanOrEqual(5_000); + } finally { + if (previousFast === undefined) { + delete process.env.OPENCLAW_TEST_FAST; + } else { + process.env.OPENCLAW_TEST_FAST = previousFast; + } + } + }); }); diff --git a/src/agents/subagent-announce-queue.ts b/src/agents/subagent-announce-queue.ts index 611541c186e7..cd99372adc8c 100644 --- a/src/agents/subagent-announce-queue.ts +++ b/src/agents/subagent-announce-queue.ts @@ -183,9 +183,10 @@ function scheduleAnnounceDrain(key: string) { queue.consecutiveFailures++; // Exponential backoff on consecutive failures: 2s, 4s, 8s, ... capped at 60s. const errorBackoffMs = Math.min(1000 * Math.pow(2, queue.consecutiveFailures), 60_000); - queue.lastEnqueuedAt = Date.now() + errorBackoffMs - queue.debounceMs; + const retryDelayMs = Math.max(errorBackoffMs, queue.debounceMs); + queue.lastEnqueuedAt = Date.now() + retryDelayMs - queue.debounceMs; defaultRuntime.error?.( - `announce queue drain failed for ${key} (attempt ${queue.consecutiveFailures}, retry in ${Math.round(errorBackoffMs / 1000)}s): ${String(err)}`, + `announce queue drain failed for ${key} (attempt ${queue.consecutiveFailures}, retry in ${Math.round(retryDelayMs / 1000)}s): ${String(err)}`, ); } finally { queue.draining = false; @@ -205,7 +206,8 @@ export function enqueueAnnounce(params: { send: (item: AnnounceQueueItem) => Promise; }): boolean { const queue = getAnnounceQueue(params.key, params.settings, params.send); - queue.lastEnqueuedAt = Date.now(); + // Preserve any retry backoff marker already encoded in lastEnqueuedAt. + queue.lastEnqueuedAt = Math.max(queue.lastEnqueuedAt, Date.now()); const shouldEnqueue = applyQueueDropPolicy({ queue, diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index f3d2c7bcf157..067273351db3 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -31,6 +31,7 @@ const deliverDiscordReply = deliveryMocks.deliverDiscordReply; const createDiscordDraftStream = deliveryMocks.createDiscordDraftStream; type DispatchInboundParams = { dispatcher: { + sendBlockReply: (payload: { text?: string }) => boolean | Promise; sendFinalReply: (payload: { text?: string }) => boolean | Promise; }; replyOptions?: { @@ -75,7 +76,10 @@ vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({ (opts: { deliver: (payload: unknown, info: { kind: string }) => Promise | void }) => ({ dispatcher: { sendToolResult: vi.fn(() => true), - sendBlockReply: vi.fn(() => true), + sendBlockReply: vi.fn((payload: unknown) => { + void opts.deliver(payload as never, { kind: "block" }); + return true; + }), sendFinalReply: vi.fn((payload: unknown) => { void opts.deliver(payload as never, { kind: "final" }); return true; @@ -423,6 +427,20 @@ describe("processDiscordMessage draft streaming", () => { expect(deliverDiscordReply).toHaveBeenCalledTimes(1); }); + it("suppresses block-kind payload delivery to Discord", async () => { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.dispatcher.sendBlockReply({ text: "thinking..." }); + return { queuedFinal: false, counts: { final: 0, tool: 0, block: 1 } }; + }); + + const ctx = await createBaseContext({ discordConfig: { streamMode: "off" } }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + expect(deliverDiscordReply).not.toHaveBeenCalled(); + }); + it("streams block previews using draft chunking", async () => { const draftStream = createMockDraftStream(); createDiscordDraftStream.mockReturnValueOnce(draftStream); diff --git a/src/gateway/agent-prompt.test.ts b/src/gateway/agent-prompt.test.ts index 80fc92e48199..75800696614c 100644 --- a/src/gateway/agent-prompt.test.ts +++ b/src/gateway/agent-prompt.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildHistoryContextFromEntries } from "../auto-reply/reply/history.js"; +import { extractTextFromChatContent } from "../shared/chat-content.js"; import { buildAgentMessageFromConversationEntries } from "./agent-prompt.js"; describe("gateway agent prompt", () => { @@ -15,6 +16,24 @@ describe("gateway agent prompt", () => { ).toBe("hi"); }); + it("extracts text from content-array body when there is no history", () => { + expect( + buildAgentMessageFromConversationEntries([ + { + role: "user", + entry: { + sender: "User", + body: [ + { type: "text", text: "hi" }, + { type: "image", data: "base64-image", mimeType: "image/png" }, + { type: "text", text: "there" }, + ] as unknown as string, + }, + }, + ]), + ).toBe("hi there"); + }); + it("uses history context when there is history", () => { const entries = [ { role: "assistant", entry: { sender: "Assistant", body: "prev" } }, @@ -45,4 +64,34 @@ describe("gateway agent prompt", () => { expect(buildAgentMessageFromConversationEntries([...entries])).toBe(expected); }); + + it("normalizes content-array bodies in history and current message", () => { + const entries = [ + { + role: "assistant", + entry: { + sender: "Assistant", + body: [{ type: "text", text: "prev" }] as unknown as string, + }, + }, + { + role: "user", + entry: { + sender: "User", + body: [ + { type: "text", text: "next" }, + { type: "text", text: "step" }, + ] as unknown as string, + }, + }, + ] as const; + + const expected = buildHistoryContextFromEntries({ + entries: entries.map((e) => e.entry), + currentMessage: "User: next step", + formatEntry: (e) => `${e.sender}: ${extractTextFromChatContent(e.body) ?? ""}`, + }); + + expect(buildAgentMessageFromConversationEntries([...entries])).toBe(expected); + }); }); diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index 3d8c575cf66f..1e3d92b33df5 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -87,6 +87,38 @@ describe("gateway sessions patch", () => { expect(res.entry.thinkingLevel).toBeUndefined(); }); + test("persists reasoningLevel=off (does not clear)", async () => { + const store: Record = {}; + const res = await applySessionsPatchToStore({ + cfg: {} as OpenClawConfig, + store, + storeKey: "agent:main:main", + patch: { key: "agent:main:main", reasoningLevel: "off" }, + }); + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.entry.reasoningLevel).toBe("off"); + }); + + test("clears reasoningLevel when patch sets null", async () => { + const store: Record = { + "agent:main:main": { reasoningLevel: "stream" } as SessionEntry, + }; + const res = await applySessionsPatchToStore({ + cfg: {} as OpenClawConfig, + store, + storeKey: "agent:main:main", + patch: { key: "agent:main:main", reasoningLevel: null }, + }); + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.entry.reasoningLevel).toBeUndefined(); + }); + test("persists elevatedLevel=off (does not clear)", async () => { const store: Record = {}; const res = await applySessionsPatchToStore({ diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 87409e7eee0d..1bc7a359b856 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -513,6 +513,50 @@ describe("installPluginFromDir", () => { expect(manifest.devDependencies?.openclaw).toBeUndefined(); expect(manifest.devDependencies?.vitest).toBe("^3.0.0"); }); + + it("uses openclaw.plugin.json id as install key when it differs from package name", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "@openclaw/cognee-openclaw", + version: "0.0.1", + openclaw: { extensions: ["./dist/index.js"] }, + }), + "utf-8", + ); + fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};", "utf-8"); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "memory-cognee", + configSchema: { type: "object", properties: {} }, + }), + "utf-8", + ); + + const infoMessages: string[] = []; + const res = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + logger: { info: (msg: string) => infoMessages.push(msg), warn: () => {} }, + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.pluginId).toBe("memory-cognee"); + expect(res.targetDir).toBe(path.join(extensionsDir, "memory-cognee")); + expect( + infoMessages.some((msg) => + msg.includes( + 'Plugin manifest id "memory-cognee" differs from npm package name "cognee-openclaw"', + ), + ), + ).toBe(true); + }); }); describe("installPluginFromNpmSpec", () => { From b902d5ade0b5cbc10313098390872c2b5757675e Mon Sep 17 00:00:00 2001 From: Mark Musson Date: Mon, 23 Feb 2026 19:52:39 +0000 Subject: [PATCH 054/408] fix(status): show pairing approval recovery hints --- src/commands/status.command.ts | 33 ++++++++++++++++++++++++ src/commands/status.test.ts | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index e06feb42af52..d21ae16f176b 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -37,6 +37,21 @@ import { resolveUpdateAvailability, } from "./status.update.js"; +function resolvePairingRecoveryContext(params: { + error?: string | null; + closeReason?: string | null; +}): { requestId: string | null } | null { + const source = [params.error, params.closeReason] + .filter((part) => typeof part === "string" && part.trim().length > 0) + .join(" "); + if (!source || !/pairing required/i.test(source)) { + return null; + } + const requestIdMatch = source.match(/requestId:\s*([^\s)]+)/i); + const requestId = requestIdMatch && requestIdMatch[1] ? requestIdMatch[1].trim() : ""; + return { requestId: requestId || null }; +} + export async function statusCommand( opts: { json?: boolean; @@ -230,6 +245,10 @@ export async function statusCommand( const suffix = self ? ` · ${self}` : ""; return `${gatewayMode} · ${target} · ${reach}${auth}${suffix}`; })(); + const pairingRecovery = resolvePairingRecoveryContext({ + error: gatewayProbe?.error ?? null, + closeReason: gatewayProbe?.close?.reason ?? null, + }); const agentsValue = (() => { const pending = @@ -399,6 +418,20 @@ export async function statusCommand( }).trimEnd(), ); + if (pairingRecovery) { + runtime.log(""); + runtime.log(theme.warn("Gateway pairing approval required.")); + if (pairingRecovery.requestId) { + runtime.log( + theme.muted( + `Recovery: ${formatCliCommand(`openclaw devices approve ${pairingRecovery.requestId}`)}`, + ), + ); + } + runtime.log(theme.muted(`Fallback: ${formatCliCommand("openclaw devices approve --latest")}`)); + runtime.log(theme.muted(`Inspect: ${formatCliCommand("openclaw devices list")}`)); + } + runtime.log(""); runtime.log(theme.heading("Security audit")); const fmtSummary = (value: { critical: number; warn: number; info: number }) => { diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 1275c0bea2c9..8092469f588d 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -479,6 +479,52 @@ describe("statusCommand", () => { expect(logs.join("\n")).toMatch(/WARN/); }); + it("prints requestId-aware recovery guidance when gateway pairing is required", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed: pairing required (requestId: req-123)", + close: { code: 1008, reason: "pairing required (requestId: req-123)" }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + runtimeLogMock.mockClear(); + await statusCommand({}, runtime as never); + const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])); + const joined = logs.join("\n"); + expect(joined).toContain("Gateway pairing approval required."); + expect(joined).toContain("devices approve req-123"); + expect(joined).toContain("devices approve --latest"); + expect(joined).toContain("devices list"); + }); + + it("prints fallback recovery guidance when pairing requestId is unavailable", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed: pairing required", + close: { code: 1008, reason: "connect failed" }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + runtimeLogMock.mockClear(); + await statusCommand({}, runtime as never); + const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])); + const joined = logs.join("\n"); + expect(joined).toContain("Gateway pairing approval required."); + expect(joined).not.toContain("devices approve req-"); + expect(joined).toContain("devices approve --latest"); + expect(joined).toContain("devices list"); + }); + it("includes sessions across agents in JSON output", async () => { const originalAgents = mocks.listAgentsForGateway.getMockImplementation(); const originalResolveStorePath = mocks.resolveStorePath.getMockImplementation(); From 69a541c3f0e1cbe1f49c224761e76368d74b4f83 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:53:29 +0000 Subject: [PATCH 055/408] fix: sanitize pairing recovery requestId hints (#24771) (thanks @markmusson) --- CHANGELOG.md | 1 + src/commands/status.command.ts | 14 +++++++++++- src/commands/status.test.ts | 40 ++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7851591e0f5..d8d550448df2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. - Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. - Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. - Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index d21ae16f176b..a613f0896ee2 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -41,6 +41,17 @@ function resolvePairingRecoveryContext(params: { error?: string | null; closeReason?: string | null; }): { requestId: string | null } | null { + const sanitizeRequestId = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + // Keep CLI guidance injection-safe: allow only compact id characters. + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(trimmed)) { + return null; + } + return trimmed; + }; const source = [params.error, params.closeReason] .filter((part) => typeof part === "string" && part.trim().length > 0) .join(" "); @@ -48,7 +59,8 @@ function resolvePairingRecoveryContext(params: { return null; } const requestIdMatch = source.match(/requestId:\s*([^\s)]+)/i); - const requestId = requestIdMatch && requestIdMatch[1] ? requestIdMatch[1].trim() : ""; + const requestId = + requestIdMatch && requestIdMatch[1] ? sanitizeRequestId(requestIdMatch[1]) : null; return { requestId: requestId || null }; } diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 8092469f588d..4532acb3ea2c 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -525,6 +525,46 @@ describe("statusCommand", () => { expect(joined).toContain("devices list"); }); + it("does not render unsafe requestId content into approval command hints", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed: pairing required (requestId: req-123;rm -rf /)", + close: { code: 1008, reason: "pairing required (requestId: req-123;rm -rf /)" }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + runtimeLogMock.mockClear(); + await statusCommand({}, runtime as never); + const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n"); + expect(joined).toContain("Gateway pairing approval required."); + expect(joined).not.toContain("devices approve req-123;rm -rf /"); + expect(joined).toContain("devices approve --latest"); + }); + + it("extracts requestId from close reason when error text omits it", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed: pairing required", + close: { code: 1008, reason: "pairing required (requestId: req-close-456)" }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + runtimeLogMock.mockClear(); + await statusCommand({}, runtime as never); + const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n"); + expect(joined).toContain("devices approve req-close-456"); + }); + it("includes sessions across agents in JSON output", async () => { const originalAgents = mocks.listAgentsForGateway.getMockImplementation(); const originalResolveStorePath = mocks.resolveStorePath.getMockImplementation(); From 3e974dc93f45c6e2847cab681244c3854505f7ec Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Mon, 23 Feb 2026 23:07:50 +0000 Subject: [PATCH 056/408] fix: don't inject reasoning: { effort: "none" } for OpenRouter when thinking is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "off" is a truthy string, so the existing guard `if (thinkingLevel && ...)` was always entering the injection block and sending `reasoning: { effort: "none" }` to every OpenRouter request — even when thinking wasn't enabled. Models that require reasoning (e.g. deepseek/deepseek-r1) reject this with: 400 Reasoning is mandatory for this endpoint and cannot be disabled. Fix: skip the reasoning injection entirely when thinkingLevel is "off". The reasoning_effort flat-field cleanup still runs. Omitting the reasoning field lets each model use its own default behavior. Co-Authored-By: Claude Sonnet 4.6 --- .../pi-embedded-runner-extraparams.test.ts | 52 +++++++++++++++++++ src/agents/pi-embedded-runner/extra-params.ts | 41 +++++++++------ 2 files changed, 76 insertions(+), 17 deletions(-) diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index a6d3e9191e88..3f5189a40ead 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -202,6 +202,58 @@ describe("applyExtraParamsToAgent", () => { return calls[0]?.headers; } + it("does not inject reasoning when thinkingLevel is off (default) for OpenRouter", () => { + // Regression: "off" is a truthy string, so the old code injected + // reasoning: { effort: "none" }, causing a 400 on models that require + // reasoning (e.g. deepseek/deepseek-r1). + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { model: "deepseek/deepseek-r1" }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "openrouter", "deepseek/deepseek-r1", undefined, "off"); + + const model = { + api: "openai-completions", + provider: "openrouter", + id: "deepseek/deepseek-r1", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]).not.toHaveProperty("reasoning"); + expect(payloads[0]).not.toHaveProperty("reasoning_effort"); + }); + + it("injects reasoning.effort when thinkingLevel is non-off for OpenRouter", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = {}; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "openrouter", "openrouter/auto", undefined, "low"); + + const model = { + api: "openai-completions", + provider: "openrouter", + id: "openrouter/auto", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.reasoning).toEqual({ effort: "low" }); + }); + it("adds OpenRouter attribution headers to stream options", () => { const { calls, agent } = createOptionsCaptureAgent(); diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 8ebacf6df68a..66b077af2329 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -435,24 +435,31 @@ function createOpenRouterWrapper( // only the nested one is sent. delete payloadObj.reasoning_effort; - const existingReasoning = payloadObj.reasoning; - - // OpenRouter treats reasoning.effort and reasoning.max_tokens as - // alternative controls. If max_tokens is already present, do not - // inject effort and do not overwrite caller-supplied reasoning. - if ( - existingReasoning && - typeof existingReasoning === "object" && - !Array.isArray(existingReasoning) - ) { - const reasoningObj = existingReasoning as Record; - if (!("max_tokens" in reasoningObj) && !("effort" in reasoningObj)) { - reasoningObj.effort = mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel); + // When thinking is "off", do not inject reasoning at all. + // Some models (e.g. deepseek/deepseek-r1) require reasoning and reject + // { effort: "none" } with "Reasoning is mandatory for this endpoint and + // cannot be disabled." Omitting the field lets each model use its own + // default reasoning behavior. + if (thinkingLevel !== "off") { + const existingReasoning = payloadObj.reasoning; + + // OpenRouter treats reasoning.effort and reasoning.max_tokens as + // alternative controls. If max_tokens is already present, do not + // inject effort and do not overwrite caller-supplied reasoning. + if ( + existingReasoning && + typeof existingReasoning === "object" && + !Array.isArray(existingReasoning) + ) { + const reasoningObj = existingReasoning as Record; + if (!("max_tokens" in reasoningObj) && !("effort" in reasoningObj)) { + reasoningObj.effort = mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel); + } + } else if (!existingReasoning) { + payloadObj.reasoning = { + effort: mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel), + }; } - } else if (!existingReasoning) { - payloadObj.reasoning = { - effort: mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel), - }; } } onPayload?.(payload); From b96d32c1c25486601667bbfc9902b241ec586170 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Mon, 23 Feb 2026 23:13:27 +0000 Subject: [PATCH 057/408] chore: fix oxfmt formatting in extraparams test Co-Authored-By: Claude Sonnet 4.6 --- src/agents/pi-embedded-runner-extraparams.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 3f5189a40ead..c96bb069a0e8 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -215,7 +215,14 @@ describe("applyExtraParamsToAgent", () => { }; const agent = { streamFn: baseStreamFn }; - applyExtraParamsToAgent(agent, undefined, "openrouter", "deepseek/deepseek-r1", undefined, "off"); + applyExtraParamsToAgent( + agent, + undefined, + "openrouter", + "deepseek/deepseek-r1", + undefined, + "off", + ); const model = { api: "openai-completions", From de0e01259a9ba5a73b2d9a527505044f1f7a1e7e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:54:15 +0000 Subject: [PATCH 058/408] fix: expand openrouter thinking-off regression coverage (#24863) (thanks @DevSecTim) --- CHANGELOG.md | 1 + .../pi-embedded-runner-extraparams.test.ts | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8d550448df2..c6ee9ff1d93c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. - Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. - Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. - Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index c96bb069a0e8..1e47be3ee1f6 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -261,6 +261,55 @@ describe("applyExtraParamsToAgent", () => { expect(payloads[0]?.reasoning).toEqual({ effort: "low" }); }); + it("removes legacy reasoning_effort and keeps reasoning unset when thinkingLevel is off", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { reasoning_effort: "high" }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "openrouter", "openrouter/auto", undefined, "off"); + + const model = { + api: "openai-completions", + provider: "openrouter", + id: "openrouter/auto", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]).not.toHaveProperty("reasoning_effort"); + expect(payloads[0]).not.toHaveProperty("reasoning"); + }); + + it("does not inject effort when payload already has reasoning.max_tokens", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { reasoning: { max_tokens: 256 } }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "openrouter", "openrouter/auto", undefined, "low"); + + const model = { + api: "openai-completions", + provider: "openrouter", + id: "openrouter/auto", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]).toEqual({ reasoning: { max_tokens: 256 } }); + }); + it("adds OpenRouter attribution headers to stream options", () => { const { calls, agent } = createOptionsCaptureAgent(); From 6f44d92d7677f1d42d3b14ad17c2e79450458991 Mon Sep 17 00:00:00 2001 From: shenghui kevin Date: Sun, 22 Feb 2026 22:03:42 -0800 Subject: [PATCH 059/408] docs: update PR_STATUS.md - all 11 PRs CI passed --- PR_STATUS.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 PR_STATUS.md diff --git a/PR_STATUS.md b/PR_STATUS.md new file mode 100644 index 000000000000..1887eca27d95 --- /dev/null +++ b/PR_STATUS.md @@ -0,0 +1,78 @@ +# OpenClaw PR Submission Status + +> Auto-maintained by agent team. Last updated: 2026-02-22 + +## PR Plan Overview + +All PRs target upstream `openclaw/openclaw` via fork `kevinWangSheng/openclaw`. +Each PR follows [CONTRIBUTING.md](./CONTRIBUTING.md) and uses the [PR template](./.github/PULL_REQUEST_TEMPLATE.md). + +## Duplicate Check + +Before submission, each PR was cross-referenced against: + +- 100+ open upstream PRs (as of 2026-02-22) +- 50 recently merged PRs +- 50+ open issues + +No overlap found with existing PRs. + +## PR Status Table + +| # | Branch | Title | Type | Status | PR URL | +| --- | -------------------------------------- | --------------------------------------------------------------------------- | -------- | --------------- | --------------------------------------------------------- | +| 1 | `security/redos-safe-regex` | fix(security): add ReDoS protection for user-controlled regex patterns | Security | CI Pass | [#23670](https://github.com/openclaw/openclaw/pull/23670) | +| 2 | `security/session-slug-crypto-random` | fix(security): use crypto.randomInt for session slug generation | Security | CI Pass | [#23671](https://github.com/openclaw/openclaw/pull/23671) | +| 3 | `fix/json-parse-crash-guard` | fix(resilience): guard JSON.parse of external process output with try-catch | Bug fix | CI Pass | [#23672](https://github.com/openclaw/openclaw/pull/23672) | +| 4 | `refactor/console-to-subsystem-logger` | refactor(logging): migrate remaining console calls to subsystem logger | Refactor | CI Pass | [#23669](https://github.com/openclaw/openclaw/pull/23669) | +| 5 | `fix/sanitize-rpc-error-messages` | fix(security): sanitize RPC error messages in signal and imessage clients | Security | CI Pass | [#23724](https://github.com/openclaw/openclaw/pull/23724) | +| 6 | `fix/download-stream-cleanup` | fix(resilience): destroy write streams on download errors | Bug fix | CI Pass | [#23726](https://github.com/openclaw/openclaw/pull/23726) | +| 7 | `fix/telegram-status-reaction-cleanup` | fix(telegram): clear done reaction when removeAckAfterReply is true | Bug fix | CI Pass | [#23728](https://github.com/openclaw/openclaw/pull/23728) | +| 8 | `fix/session-cache-eviction` | fix(memory): add max size eviction to session manager cache | Bug fix | CI Pass (17/17) | [#23744](https://github.com/openclaw/openclaw/pull/23744) | +| 9 | `fix/fetch-missing-timeout` | fix(resilience): add timeout to unguarded fetch calls in browser subsystem | Bug fix | CI Pass (18/18) | [#23745](https://github.com/openclaw/openclaw/pull/23745) | +| 10 | `fix/skills-download-partial-cleanup` | fix(resilience): clean up partial file on skill download failure | Bug fix | CI Pass (19/19) | [#24141](https://github.com/openclaw/openclaw/pull/24141) | +| 11 | `fix/extension-relay-stop-cleanup` | fix(browser): flush pending extension timers on relay stop | Bug fix | CI Pass (20/20) | [#24142](https://github.com/openclaw/openclaw/pull/24142) | + +## Isolation Rules + +- Each agent works on a separate git worktree branch +- No two agents modify the same file +- File ownership: + - PR 1: `src/infra/exec-approval-forwarder.ts`, `src/discord/monitor/exec-approvals.ts` + - PR 2: `src/agents/session-slug.ts` + - PR 3: `src/infra/bonjour-discovery.ts`, `src/infra/outbound/delivery-queue.ts` + - PR 4: `src/infra/tailscale.ts`, `src/node-host/runner.ts` + - PR 5: `src/signal/client.ts`, `src/imessage/client.ts` + - PR 6: `src/media/store.ts`, `src/commands/signal-install.ts` + - PR 7: `src/telegram/bot-message-dispatch.ts` + - PR 8: `src/agents/pi-embedded-runner/session-manager-cache.ts` + - PR 9: `src/cli/nodes-camera.ts`, `src/browser/pw-session.ts` + - PR 10: `src/agents/skills-install-download.ts` + - PR 11: `src/browser/extension-relay.ts` + +## Verification Results + +### Batch 1 (PRs 1-4) — All CI Green + +- PR 1: 17 tests pass, check/build/tests all green +- PR 2: 3 tests pass, check/build/tests all green +- PR 3: 45 tests pass (3 new), check/build/tests all green +- PR 4: 12 tests pass, check/build/tests all green + +### Batch 2 (PRs 5-7) — CI Running + +- PR 5: 3 signal tests pass, check pass, awaiting full test suite +- PR 6: 38 tests pass (20 media + 18 signal-install), check pass, awaiting full suite +- PR 7: 47 tests pass (3 new), check pass, awaiting full suite + +### Batch 3 (PRs 8-9) — All CI Green + +- PR 8 & 9: Initially failed due to pre-existing upstream TS errors + Windows flaky test. Fixed by rebasing onto latest upstream/main and removing `yieldMs: 10` from flaky sandbox test. +- PR 8: 17/17 pass, check/build/tests/windows all green +- PR 9: 18/18 pass, check/build/tests/windows all green + +### Batch 4 (PRs 10-11) — All CI Green + +- PR 10 & 11: Initially failed Windows flaky test (`yieldMs: 10` race). Fixed by removing `yieldMs: 10` from flaky sandbox test (same fix as PRs 8-9). +- PR 10: 19/19 pass, check/build/tests/windows all green +- PR 11: 20/20 pass, check/build/tests/windows all green From 57783680ade2a8f668b5ad89b844efa23e771662 Mon Sep 17 00:00:00 2001 From: shenghui kevin Date: Mon, 23 Feb 2026 17:56:51 -0800 Subject: [PATCH 060/408] fix(whatsapp): guard updateLastRoute when dmScope isolates DM sessions When session.dmScope is set to 'per-channel-peer', WhatsApp DMs correctly resolve isolated session keys, but updateLastRouteInBackground unconditionally wrote lastTo to the main session key. This caused reply routing corruption and privacy violations. Only update main session's lastRoute when the DM session actually IS the main session (sessionKey === mainSessionKey). Fixes #24912 --- src/web/auto-reply/monitor/process-message.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index 15607a4524ea..3ef85b6eb2df 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -324,7 +324,10 @@ export async function processMessage(params: { OriginatingTo: params.msg.from, }); - if (dmRouteTarget) { + // Only update main session's lastRoute when DM actually IS the main session. + // When dmScope="per-channel-peer", the DM uses an isolated sessionKey, + // and updating mainSessionKey would corrupt routing for the session owner. + if (dmRouteTarget && params.route.sessionKey === params.route.mainSessionKey) { updateLastRouteInBackground({ cfg: params.cfg, backgroundTasks: params.backgroundTasks, From ebde897bb8066c6e824da123f9826b1cb176c262 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:55:10 +0000 Subject: [PATCH 061/408] fix: add dmScope route guard regression tests (#24949) (thanks @kevinWangSheng) --- CHANGELOG.md | 1 + .../process-message.inbound-contract.test.ts | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6ee9ff1d93c..e0421383603f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. - Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. - Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. - Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. diff --git a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts index 0acd4056fc92..8458487d8e96 100644 --- a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -84,6 +84,7 @@ vi.mock("../deliver-reply.js", () => ({ deliverWebReply: deliverWebReplyMock, })); +import { updateLastRouteInBackground } from "./last-route.js"; import { processMessage } from "./process-message.js"; describe("web processMessage inbound contract", () => { @@ -302,4 +303,58 @@ describe("web processMessage inbound contract", () => { const replyOptions = (capturedDispatchParams as any)?.replyOptions; expect(replyOptions?.disableBlockStreaming).toBe(true); }); + + it("updates main last route for DM when session key matches main session key", async () => { + const updateLastRouteMock = vi.mocked(updateLastRouteInBackground); + updateLastRouteMock.mockClear(); + + const args = makeProcessMessageArgs({ + routeSessionKey: "agent:main:whatsapp:direct:+1000", + groupHistoryKey: "+1000", + msg: { + id: "msg-last-route-1", + from: "+1000", + to: "+2000", + chatType: "direct", + body: "hello", + senderE164: "+1000", + }, + }); + args.route = { + ...args.route, + sessionKey: "agent:main:whatsapp:direct:+1000", + mainSessionKey: "agent:main:whatsapp:direct:+1000", + }; + + await processMessage(args); + + expect(updateLastRouteMock).toHaveBeenCalledTimes(1); + }); + + it("does not update main last route for isolated DM scope sessions", async () => { + const updateLastRouteMock = vi.mocked(updateLastRouteInBackground); + updateLastRouteMock.mockClear(); + + const args = makeProcessMessageArgs({ + routeSessionKey: "agent:main:whatsapp:dm:+1000:peer:+3000", + groupHistoryKey: "+3000", + msg: { + id: "msg-last-route-2", + from: "+3000", + to: "+2000", + chatType: "direct", + body: "hello", + senderE164: "+3000", + }, + }); + args.route = { + ...args.route, + sessionKey: "agent:main:whatsapp:dm:+1000:peer:+3000", + mainSessionKey: "agent:main:whatsapp:direct:+1000", + }; + + await processMessage(args); + + expect(updateLastRouteMock).not.toHaveBeenCalled(); + }); }); From a388fbb6c3129f48a67f9f3db788c83f1d545bb7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:47:32 +0000 Subject: [PATCH 062/408] fix: harden custom-provider verification probes (#24743) (thanks @Glucksberg) --- CHANGELOG.md | 1 + src/commands/onboard-custom.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0421383603f..036017f16e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Onboarding/Custom providers: raise verification probe token budgets for OpenAI and Anthropic compatibility checks to avoid false negatives on strict provider defaults. (#24743) Thanks @Glucksberg. - WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. - Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. - Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index c1bf8aa0d8d5..c79c30daff26 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -116,6 +116,35 @@ describe("promptCustomApiConfig", () => { expectOpenAiCompatResult({ prompter, textCalls: 5, selectCalls: 1, result }); }); + it("uses expanded max_tokens for openai verification probes", async () => { + const prompter = createTestPrompter({ + text: ["https://example.com/v1", "test-key", "detected-model", "custom", "alias"], + select: ["openai"], + }); + const fetchMock = stubFetchSequence([{ ok: true }]); + + await runPromptCustomApi(prompter); + + const firstCall = fetchMock.mock.calls[0]?.[1] as { body?: string } | undefined; + expect(firstCall?.body).toBeDefined(); + expect(JSON.parse(firstCall?.body ?? "{}")).toMatchObject({ max_tokens: 1024 }); + }); + + it("uses expanded max_tokens for anthropic verification probes", async () => { + const prompter = createTestPrompter({ + text: ["https://example.com", "test-key", "detected-model", "custom", "alias"], + select: ["unknown"], + }); + const fetchMock = stubFetchSequence([{ ok: false, status: 404 }, { ok: true }]); + + await runPromptCustomApi(prompter); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const secondCall = fetchMock.mock.calls[1]?.[1] as { body?: string } | undefined; + expect(secondCall?.body).toBeDefined(); + expect(JSON.parse(secondCall?.body ?? "{}")).toMatchObject({ max_tokens: 1024 }); + }); + it("re-prompts base url when unknown detection fails", async () => { const prompter = createTestPrompter({ text: [ From d76742ff88d58288a5591a19713858822faf99ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:56:27 +0000 Subject: [PATCH 063/408] fix: normalize manifest plugin ids during install --- src/plugins/install.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/plugins/install.ts | 4 +++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 1bc7a359b856..9f67e69430bd 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -557,6 +557,43 @@ describe("installPluginFromDir", () => { ), ).toBe(true); }); + + it("normalizes scoped manifest ids to unscoped install keys", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "@openclaw/cognee-openclaw", + version: "0.0.1", + openclaw: { extensions: ["./dist/index.js"] }, + }), + "utf-8", + ); + fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};", "utf-8"); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "@team/memory-cognee", + configSchema: { type: "object", properties: {} }, + }), + "utf-8", + ); + + const res = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + expectedPluginId: "memory-cognee", + logger: { info: () => {}, warn: () => {} }, + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.pluginId).toBe("memory-cognee"); + expect(res.targetDir).toBe(path.join(extensionsDir, "memory-cognee")); + }); }); describe("installPluginFromNpmSpec", () => { diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 49ce72dcd07f..baf3eb690ad7 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -158,7 +158,9 @@ async function installPluginFromPackageDir(params: { // uses the manifest id as the authoritative key, so the config entry must match it. const ocManifestResult = loadPluginManifest(params.packageDir); const manifestPluginId = - ocManifestResult.ok && ocManifestResult.manifest.id ? ocManifestResult.manifest.id : undefined; + ocManifestResult.ok && ocManifestResult.manifest.id + ? unscopedPackageName(ocManifestResult.manifest.id) + : undefined; const pluginId = manifestPluginId ?? npmPluginId; const pluginIdError = validatePluginId(pluginId); From 588a188d6f88ed418b6d38943439601817f98f91 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:01:41 +0000 Subject: [PATCH 064/408] fix: replace stale plugin webhook routes on re-registration --- src/plugins/http-registry.test.ts | 78 +++++++++++++++++++++++++++++++ src/plugins/http-registry.ts | 7 +-- 2 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 src/plugins/http-registry.test.ts diff --git a/src/plugins/http-registry.test.ts b/src/plugins/http-registry.test.ts new file mode 100644 index 000000000000..fca12e4dc113 --- /dev/null +++ b/src/plugins/http-registry.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import { registerPluginHttpRoute } from "./http-registry.js"; +import { createEmptyPluginRegistry } from "./registry.js"; + +describe("registerPluginHttpRoute", () => { + it("registers route and unregisters it", () => { + const registry = createEmptyPluginRegistry(); + const handler = vi.fn(); + + const unregister = registerPluginHttpRoute({ + path: "/plugins/demo", + handler, + registry, + }); + + expect(registry.httpRoutes).toHaveLength(1); + expect(registry.httpRoutes[0]?.path).toBe("/plugins/demo"); + expect(registry.httpRoutes[0]?.handler).toBe(handler); + + unregister(); + expect(registry.httpRoutes).toHaveLength(0); + }); + + it("returns noop unregister when path is missing", () => { + const registry = createEmptyPluginRegistry(); + const logs: string[] = []; + const unregister = registerPluginHttpRoute({ + path: "", + handler: vi.fn(), + registry, + accountId: "default", + log: (msg) => logs.push(msg), + }); + + expect(registry.httpRoutes).toHaveLength(0); + expect(logs).toEqual(['plugin: webhook path missing for account "default"']); + expect(() => unregister()).not.toThrow(); + }); + + it("replaces stale route on same path and keeps latest registration", () => { + const registry = createEmptyPluginRegistry(); + const logs: string[] = []; + const firstHandler = vi.fn(); + const secondHandler = vi.fn(); + + const unregisterFirst = registerPluginHttpRoute({ + path: "/plugins/synology", + handler: firstHandler, + registry, + accountId: "default", + pluginId: "synology-chat", + log: (msg) => logs.push(msg), + }); + + const unregisterSecond = registerPluginHttpRoute({ + path: "/plugins/synology", + handler: secondHandler, + registry, + accountId: "default", + pluginId: "synology-chat", + log: (msg) => logs.push(msg), + }); + + expect(registry.httpRoutes).toHaveLength(1); + expect(registry.httpRoutes[0]?.handler).toBe(secondHandler); + expect(logs).toContain( + 'plugin: replacing stale webhook path /plugins/synology for account "default" (synology-chat)', + ); + + // Old unregister must not remove the replacement route. + unregisterFirst(); + expect(registry.httpRoutes).toHaveLength(1); + expect(registry.httpRoutes[0]?.handler).toBe(secondHandler); + + unregisterSecond(); + expect(registry.httpRoutes).toHaveLength(0); + }); +}); diff --git a/src/plugins/http-registry.ts b/src/plugins/http-registry.ts index 5e2df3b522dd..5987fd173705 100644 --- a/src/plugins/http-registry.ts +++ b/src/plugins/http-registry.ts @@ -29,10 +29,11 @@ export function registerPluginHttpRoute(params: { return () => {}; } - if (routes.some((entry) => entry.path === normalizedPath)) { + const existingIndex = routes.findIndex((entry) => entry.path === normalizedPath); + if (existingIndex >= 0) { const pluginHint = params.pluginId ? ` (${params.pluginId})` : ""; - params.log?.(`plugin: webhook path ${normalizedPath} already registered${suffix}${pluginHint}`); - return () => {}; + params.log?.(`plugin: replacing stale webhook path ${normalizedPath}${suffix}${pluginHint}`); + routes.splice(existingIndex, 1); } const entry: PluginHttpRouteRegistration = { From aea28e26fb592cf56f03bf342f640d8a707d2410 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:02:18 +0000 Subject: [PATCH 065/408] fix(auto-reply): expand standalone stop phrases --- CHANGELOG.md | 1 + docs/concepts/session.md | 2 +- docs/help/faq.md | 13 +++++++++ docs/web/control-ui.md | 2 +- src/auto-reply/reply/abort.test.ts | 45 ++++++++++++++++++++++++------ src/auto-reply/reply/abort.ts | 43 ++++++++++++++++++++++++++-- 6 files changed, 92 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 036017f16e79..6df352d6c8f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. - Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. - Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. +- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants) and accept trailing punctuation (for example `STOP OPENCLAW!!!`) so emergency stop messages are caught more reliably. - Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) - Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) - Sessions/Reasoning: persist `reasoningLevel: "off"` explicitly instead of deleting it so session overrides survive patch/update flows. (#24406, #24559) diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 81550a032ed8..6c9010d2c11e 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -283,7 +283,7 @@ Runtime override (owner only): - `openclaw gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access). - Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). - Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors). -- Send `/stop` as a standalone message to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count). +- Send `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`) to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count). - Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction). - JSONL transcripts can be opened directly to review full turns. diff --git a/docs/help/faq.md b/docs/help/faq.md index d6a5f3f1205e..4cf1c7447ed7 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -2814,6 +2814,19 @@ Send any of these **as a standalone message** (no slash): ``` stop +stop action +stop current action +stop run +stop current run +stop agent +stop the agent +stop openclaw +openclaw stop +stop don't do anything +stop do not do anything +stop doing anything +please stop +stop please abort esc wait diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index b1ff11c32436..ad6d2393523a 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -99,7 +99,7 @@ Cron jobs panel notes: - `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery). - Stop: - Click **Stop** (calls `chat.abort`) - - Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band + - Type `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`, `please stop`) to abort out-of-band - `chat.abort` supports `{ sessionKey }` (no `runId`) to abort all active runs for that session - Abort partial retention: - When a run is aborted, partial assistant text can still be shown in the UI diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index f5bca4b677aa..b36855eb80c1 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -122,25 +122,52 @@ describe("abort detection", () => { expect(result.triggerBodyNormalized).toBe("/stop"); }); - it("isAbortTrigger matches bare word triggers (without slash)", () => { - expect(isAbortTrigger("stop")).toBe(true); - expect(isAbortTrigger("esc")).toBe(true); - expect(isAbortTrigger("abort")).toBe(true); - expect(isAbortTrigger("wait")).toBe(true); - expect(isAbortTrigger("exit")).toBe(true); - expect(isAbortTrigger("interrupt")).toBe(true); + it("isAbortTrigger matches standalone abort trigger phrases", () => { + const positives = [ + "stop", + "esc", + "abort", + "wait", + "exit", + "interrupt", + "stop openclaw", + "openclaw stop", + "stop action", + "stop current action", + "stop run", + "stop current run", + "stop agent", + "stop the agent", + "stop don't do anything", + "stop dont do anything", + "stop do not do anything", + "stop doing anything", + "please stop", + "stop please", + "STOP OPENCLAW", + "stop openclaw!!!", + "stop don’t do anything", + ]; + for (const candidate of positives) { + expect(isAbortTrigger(candidate)).toBe(true); + } + expect(isAbortTrigger("hello")).toBe(false); - // /stop is NOT matched by isAbortTrigger - it's handled separately + expect(isAbortTrigger("do not do that")).toBe(false); + // /stop is NOT matched by isAbortTrigger - it's handled separately. expect(isAbortTrigger("/stop")).toBe(false); }); it("isAbortRequestText aligns abort command semantics", () => { expect(isAbortRequestText("/stop")).toBe(true); + expect(isAbortRequestText("/stop!!!")).toBe(true); expect(isAbortRequestText("stop")).toBe(true); + expect(isAbortRequestText("stop action")).toBe(true); + expect(isAbortRequestText("stop openclaw!!!")).toBe(true); expect(isAbortRequestText("/stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true); expect(isAbortRequestText("/status")).toBe(false); - expect(isAbortRequestText("stop please")).toBe(false); + expect(isAbortRequestText("do not do that")).toBe(false); expect(isAbortRequestText("/abort")).toBe(false); }); diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index 4cb894830779..38bf576a435c 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -23,15 +23,47 @@ import type { FinalizedMsgContext, MsgContext } from "../templating.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { clearSessionQueues } from "./queue.js"; -const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit", "interrupt"]); +const ABORT_TRIGGERS = new Set([ + "stop", + "esc", + "abort", + "wait", + "exit", + "interrupt", + "stop openclaw", + "openclaw stop", + "stop action", + "stop current action", + "stop run", + "stop current run", + "stop agent", + "stop the agent", + "stop don't do anything", + "stop dont do anything", + "stop do not do anything", + "stop doing anything", + "please stop", + "stop please", +]); const ABORT_MEMORY = new Map(); const ABORT_MEMORY_MAX = 2000; +const TRAILING_ABORT_PUNCTUATION_RE = /[.!?…,,。;;::'"’”)\]}]+$/u; + +function normalizeAbortTriggerText(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/[’`]/g, "'") + .replace(/\s+/g, " ") + .replace(TRAILING_ABORT_PUNCTUATION_RE, "") + .trim(); +} export function isAbortTrigger(text?: string): boolean { if (!text) { return false; } - const normalized = text.trim().toLowerCase(); + const normalized = normalizeAbortTriggerText(text); return ABORT_TRIGGERS.has(normalized); } @@ -43,7 +75,12 @@ export function isAbortRequestText(text?: string, options?: CommandNormalizeOpti if (!normalized) { return false; } - return normalized.toLowerCase() === "/stop" || isAbortTrigger(normalized); + const normalizedLower = normalized.toLowerCase(); + return ( + normalizedLower === "/stop" || + normalizeAbortTriggerText(normalizedLower) === "/stop" || + isAbortTrigger(normalizedLower) + ); } export function getAbortMemory(key: string): boolean | undefined { From 9d3bd50990087f7c55a060b43cfc3ac89e733027 Mon Sep 17 00:00:00 2001 From: SidQin-cyber Date: Tue, 24 Feb 2026 10:13:00 +0800 Subject: [PATCH 066/408] fix(otel): use protobuf OTLP exporters instead of JSON/HTTP The diagnostics-otel extension validates that protocol is "http/protobuf" but was importing JSON-based `-http` exporters. This caused silent failures with backends like VictoriaMetrics that only accept protobuf-encoded OTLP. Switch all three exporter imports (metrics, traces, logs) from `@opentelemetry/exporter-*-otlp-http` to `@opentelemetry/exporter-*-otlp-proto`. Fixes #24942 Co-authored-by: Cursor (cherry picked from commit f5c0bf0497bff4c9c0748472ad5a63742af43374) --- extensions/diagnostics-otel/package.json | 6 +++--- extensions/diagnostics-otel/src/service.test.ts | 6 +++--- extensions/diagnostics-otel/src/service.ts | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index 994e9edb58a8..f35358809cf9 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -6,9 +6,9 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.212.0", - "@opentelemetry/exporter-logs-otlp-http": "^0.212.0", - "@opentelemetry/exporter-metrics-otlp-http": "^0.212.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.212.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.212.0", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.212.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.212.0", "@opentelemetry/resources": "^2.5.1", "@opentelemetry/sdk-logs": "^0.212.0", "@opentelemetry/sdk-metrics": "^2.5.1", diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index 8189ecaec8c7..ab3fb57e15aa 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -51,11 +51,11 @@ vi.mock("@opentelemetry/sdk-node", () => ({ }, })); -vi.mock("@opentelemetry/exporter-metrics-otlp-http", () => ({ +vi.mock("@opentelemetry/exporter-metrics-otlp-proto", () => ({ OTLPMetricExporter: class {}, })); -vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ +vi.mock("@opentelemetry/exporter-trace-otlp-proto", () => ({ OTLPTraceExporter: class { constructor(options?: unknown) { traceExporterCtor(options); @@ -63,7 +63,7 @@ vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ }, })); -vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({ +vi.mock("@opentelemetry/exporter-logs-otlp-proto", () => ({ OTLPLogExporter: class {}, })); diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 0749708c8810..be9a547963f1 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -1,8 +1,8 @@ import { metrics, trace, SpanStatusCode } from "@opentelemetry/api"; import type { SeverityNumber } from "@opentelemetry/api-logs"; -import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; -import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; -import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; import { resourceFromAttributes } from "@opentelemetry/resources"; import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs"; import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; @@ -657,7 +657,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { }); if (logsEnabled) { - ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/HTTP)"); + ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/Protobuf)"); } }, async stop() { From 8d2035633b89e560132406b90d86f5cbfff602d2 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 24 Feb 2026 10:35:10 +0800 Subject: [PATCH 067/408] fix(agents): include SOUL.md, IDENTITY.md, USER.md in subagent/cron bootstrap allowlist Subagent and isolated cron sessions only loaded AGENTS.md and TOOLS.md, causing subagents to lose their role personality, identity, and user preferences. Expand MINIMAL_BOOTSTRAP_ALLOWLIST to include the three missing identity files. Closes #24852 (cherry picked from commit c33377150eeddb42c2a24f4a48c2d01b5cdf8d3e) --- src/agents/workspace.test.ts | 51 +++++++++++++++++++ src/agents/workspace.ts | 8 ++- .../bootstrap-extra-files/handler.test.ts | 7 ++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index 2fef954c1f76..0c8541789177 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -11,8 +11,10 @@ import { DEFAULT_TOOLS_FILENAME, DEFAULT_USER_FILENAME, ensureAgentWorkspace, + filterBootstrapFilesForSession, loadWorkspaceBootstrapFiles, resolveDefaultAgentWorkspaceDir, + type WorkspaceBootstrapFile, } from "./workspace.js"; describe("resolveDefaultAgentWorkspaceDir", () => { @@ -141,3 +143,52 @@ describe("loadWorkspaceBootstrapFiles", () => { expect(getMemoryEntries(files)).toHaveLength(0); }); }); + +describe("filterBootstrapFilesForSession", () => { + const mockFiles: WorkspaceBootstrapFile[] = [ + { name: "AGENTS.md", path: "/w/AGENTS.md", content: "", missing: false }, + { name: "SOUL.md", path: "/w/SOUL.md", content: "", missing: false }, + { name: "TOOLS.md", path: "/w/TOOLS.md", content: "", missing: false }, + { name: "IDENTITY.md", path: "/w/IDENTITY.md", content: "", missing: false }, + { name: "USER.md", path: "/w/USER.md", content: "", missing: false }, + { name: "HEARTBEAT.md", path: "/w/HEARTBEAT.md", content: "", missing: false }, + { name: "BOOTSTRAP.md", path: "/w/BOOTSTRAP.md", content: "", missing: false }, + { name: "MEMORY.md", path: "/w/MEMORY.md", content: "", missing: false }, + ]; + + it("returns all files for main session (no sessionKey)", () => { + const result = filterBootstrapFilesForSession(mockFiles); + expect(result).toHaveLength(mockFiles.length); + }); + + it("returns all files for normal (non-subagent, non-cron) session key", () => { + const result = filterBootstrapFilesForSession(mockFiles, "agent:default:chat:main"); + expect(result).toHaveLength(mockFiles.length); + }); + + it("filters to allowlist for subagent sessions", () => { + const result = filterBootstrapFilesForSession(mockFiles, "agent:default:subagent:task-1"); + const names = result.map((f) => f.name); + expect(names).toContain("AGENTS.md"); + expect(names).toContain("TOOLS.md"); + expect(names).toContain("SOUL.md"); + expect(names).toContain("IDENTITY.md"); + expect(names).toContain("USER.md"); + expect(names).not.toContain("HEARTBEAT.md"); + expect(names).not.toContain("BOOTSTRAP.md"); + expect(names).not.toContain("MEMORY.md"); + }); + + it("filters to allowlist for cron sessions", () => { + const result = filterBootstrapFilesForSession(mockFiles, "agent:default:cron:daily-check"); + const names = result.map((f) => f.name); + expect(names).toContain("AGENTS.md"); + expect(names).toContain("TOOLS.md"); + expect(names).toContain("SOUL.md"); + expect(names).toContain("IDENTITY.md"); + expect(names).toContain("USER.md"); + expect(names).not.toContain("HEARTBEAT.md"); + expect(names).not.toContain("BOOTSTRAP.md"); + expect(names).not.toContain("MEMORY.md"); + }); +}); diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index c0bd5d63386b..dbef9c6517da 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -494,7 +494,13 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise { const event = createHookEvent("agent", "bootstrap", "agent:main:subagent:abc", context); await handler(event); - - expect(context.bootstrapFiles.map((f) => f.name).toSorted()).toEqual(["AGENTS.md", "TOOLS.md"]); + expect(context.bootstrapFiles.map((f) => f.name).toSorted()).toEqual([ + "AGENTS.md", + "SOUL.md", + "TOOLS.md", + ]); }); }); From 3129d1c489f5d2a8a9818c917aad86ae7f66eb02 Mon Sep 17 00:00:00 2001 From: Ian Eaves Date: Sun, 22 Feb 2026 16:50:06 -0600 Subject: [PATCH 068/408] fix(gateway): start browser HTTP control server module --- src/gateway/server-browser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gateway/server-browser.ts b/src/gateway/server-browser.ts index 02f3659de3cb..5f2436f431df 100644 --- a/src/gateway/server-browser.ts +++ b/src/gateway/server-browser.ts @@ -11,7 +11,7 @@ export async function startBrowserControlServerIfEnabled(): Promise Date: Tue, 24 Feb 2026 04:05:31 +0000 Subject: [PATCH 069/408] docs(changelog): note browser control startup import fix (#23974) (thanks @ieaves) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6df352d6c8f6..da864a8531e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Browser control: load `src/browser/server.js` during browser-control startup so the control listener starts reliably when browser control is enabled. (#23974) Thanks @ieaves. - Onboarding/Custom providers: raise verification probe token budgets for OpenAI and Anthropic compatibility checks to avoid false negatives on strict provider defaults. (#24743) Thanks @Glucksberg. - WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. - Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. From dd41a784586c8030ca19a0f3fd8bcd626fc7b824 Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Mon, 23 Feb 2026 11:07:49 -0300 Subject: [PATCH 070/408] fix(bluebubbles): pass SSRF policy for localhost attachment downloads (#24457) (cherry picked from commit aff64567c757ac46aad320b53406b5036361ff65) --- extensions/bluebubbles/src/account-resolve.ts | 8 +++- .../bluebubbles/src/attachments.test.ts | 43 +++++++++++++++++++ extensions/bluebubbles/src/attachments.ts | 3 +- extensions/bluebubbles/src/config-schema.ts | 1 + extensions/bluebubbles/src/types.ts | 2 + 5 files changed, 55 insertions(+), 2 deletions(-) diff --git a/extensions/bluebubbles/src/account-resolve.ts b/extensions/bluebubbles/src/account-resolve.ts index 0ec539644fef..904d21d4d3f2 100644 --- a/extensions/bluebubbles/src/account-resolve.ts +++ b/extensions/bluebubbles/src/account-resolve.ts @@ -12,6 +12,7 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv baseUrl: string; password: string; accountId: string; + allowPrivateNetwork: boolean; } { const account = resolveBlueBubblesAccount({ cfg: params.cfg ?? {}, @@ -25,5 +26,10 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv if (!password) { throw new Error("BlueBubbles password is required"); } - return { baseUrl, password, accountId: account.accountId }; + return { + baseUrl, + password, + accountId: account.accountId, + allowPrivateNetwork: account.config.allowPrivateNetwork === true, + }; } diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 7ebab0485df7..d6b12d311f88 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -268,6 +268,49 @@ describe("downloadBlueBubblesAttachment", () => { expect(calledUrl).toContain("password=config-password"); expect(result.buffer).toEqual(new Uint8Array([1])); }); + + it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-ssrf" }; + await downloadBlueBubblesAttachment(attachment, { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test", + allowPrivateNetwork: true, + }, + }, + }, + }); + + const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; + expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true }); + }); + + it("does not pass ssrfPolicy when allowPrivateNetwork is not set", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-no-ssrf" }; + await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; + expect(fetchMediaArgs.ssrfPolicy).toBeUndefined(); + }); }); describe("sendBlueBubblesAttachment", () => { diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 3b8850f21540..6ccb043845f0 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -82,7 +82,7 @@ export async function downloadBlueBubblesAttachment( if (!guid) { throw new Error("BlueBubbles attachment guid is required"); } - const { baseUrl, password } = resolveAccount(opts); + const { baseUrl, password, allowPrivateNetwork } = resolveAccount(opts); const url = buildBlueBubblesApiUrl({ baseUrl, path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`, @@ -94,6 +94,7 @@ export async function downloadBlueBubblesAttachment( url, filePathHint: attachment.transferName ?? attachment.guid ?? "attachment", maxBytes, + ssrfPolicy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, fetchImpl: async (input, init) => await blueBubblesFetchWithTimeout( resolveRequestUrl(input), diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index b575ab85fe16..e4bef3fd73bb 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -43,6 +43,7 @@ const bluebubblesAccountSchema = z mediaMaxMb: z.number().int().positive().optional(), mediaLocalRoots: z.array(z.string()).optional(), sendReadReceipts: z.boolean().optional(), + allowPrivateNetwork: z.boolean().optional(), blockStreaming: z.boolean().optional(), groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(), }) diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index 7346c4ff42a0..72ccd9918570 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -53,6 +53,8 @@ export type BlueBubblesAccountConfig = { mediaLocalRoots?: string[]; /** Send read receipts for incoming messages (default: true). */ sendReadReceipts?: boolean; + /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for same-host BlueBubbles setups. */ + allowPrivateNetwork?: boolean; /** Per-group configuration keyed by chat GUID or identifier. */ groups?: Record; }; From 721d8b2278fe578a5ee49f4bbd1da1bbcc9578d0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:10:52 +0000 Subject: [PATCH 071/408] test(discord): stabilize parent-info + doctor migration assertions (#25028) --- ...-routing-allowfrom-channels-whatsapp-allowfrom.test.ts | 8 +++++--- src/discord/monitor/threading.parent-info.test.ts | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts b/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts index 95fe4be23f44..4cece369684d 100644 --- a/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts +++ b/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts @@ -54,9 +54,11 @@ describe("doctor command", () => { const remote = gateway.remote as Record; const channels = (written.channels as Record) ?? {}; - expect(channels.whatsapp).toEqual({ - allowFrom: ["+15555550123"], - }); + expect(channels.whatsapp).toEqual( + expect.objectContaining({ + allowFrom: ["+15555550123"], + }), + ); expect(written.routing).toBeUndefined(); expect(remote.token).toBe("legacy-remote-token"); expect(auth).toBeUndefined(); diff --git a/src/discord/monitor/threading.parent-info.test.ts b/src/discord/monitor/threading.parent-info.test.ts index 1954dd4fe9d6..6d2d169002c3 100644 --- a/src/discord/monitor/threading.parent-info.test.ts +++ b/src/discord/monitor/threading.parent-info.test.ts @@ -1,8 +1,13 @@ import { ChannelType } from "@buape/carbon"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { __resetDiscordChannelInfoCacheForTest } from "./message-utils.js"; import { resolveDiscordThreadParentInfo } from "./threading.js"; describe("resolveDiscordThreadParentInfo", () => { + beforeEach(() => { + __resetDiscordChannelInfoCacheForTest(); + }); + it("falls back to fetched thread parentId when parentId is missing in payload", async () => { const fetchChannel = vi.fn(async (channelId: string) => { if (channelId === "thread-1") { From 67bac62c2c7065bdbe4a51beb9ac3f6596c40aca Mon Sep 17 00:00:00 2001 From: NK Date: Tue, 17 Feb 2026 20:29:07 -0800 Subject: [PATCH 072/408] fix: Chrome relay extension auto-reattach after SPA navigation When Chrome's debugger detaches during page navigation (common in SPAs like Gmail, Google Calendar), the extension now automatically re-attaches instead of permanently losing the connection. Changes: - onDebuggerDetach: detect navigation vs tab close, attempt re-attach with 3 retries and exponential backoff (300ms, 700ms, 1500ms) - Add reattachPending guard to prevent concurrent re-attach races - connectOrToggleForActiveTab: handle pending re-attach state - onRelayClosed: clear reattachPending on relay disconnect - Add chrome.tabs.onRemoved listener for proper cleanup Fixes #19744 --- assets/chrome-extension/background.js | 136 ++++++++++++++++++++------ 1 file changed, 104 insertions(+), 32 deletions(-) diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index b149f8745dc3..294d9f87b2b7 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -30,6 +30,10 @@ const pending = new Map() /** @type {Set} */ const tabOperationLocks = new Set() +// Tabs currently in a detach/re-attach cycle after navigation. +/** @type {Set} */ +const reattachPending = new Set() + // Reconnect state for exponential backoff. let reconnectAttempt = 0 let reconnectTimer = null @@ -190,6 +194,8 @@ function onRelayClosed(reason) { p.reject(new Error(`Relay disconnected (${reason})`)) } + reattachPending.clear() + for (const [tabId, tab] of tabs.entries()) { if (tab.state === 'connected') { setBadge(tabId, 'connecting') @@ -493,6 +499,16 @@ async function connectOrToggleForActiveTab() { tabOperationLocks.add(tabId) try { + if (reattachPending.has(tabId)) { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay (click to attach/detach)', + }) + return + } + const existing = tabs.get(tabId) if (existing?.state === 'connected') { await detachTab(tabId, 'toggle') @@ -632,50 +648,106 @@ function onDebuggerEvent(source, method, params) { } } -// Navigation/reload fires target_closed but the tab is still alive — Chrome -// just swaps the renderer process. Suppress the detach event to the relay and -// seamlessly re-attach after a short grace period. -function onDebuggerDetach(source, reason) { +async function onDebuggerDetach(source, reason) { const tabId = source.tabId if (!tabId) return if (!tabs.has(tabId)) return - if (reason === 'target_closed') { - const oldState = tabs.get(tabId) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: re-attaching after navigation…', - }) + if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') { + void detachTab(tabId, reason) + return + } - setTimeout(async () => { - try { - // If user manually detached during the grace period, bail out. - if (!tabs.has(tabId)) return - const tab = await chrome.tabs.get(tabId) - if (tab && relayWs?.readyState === WebSocket.OPEN) { - console.log(`Re-attaching tab ${tabId} after navigation`) - if (oldState?.sessionId) tabBySession.delete(oldState.sessionId) - tabs.delete(tabId) - await attachTab(tabId, { skipAttachedEvent: false }) - } else { - // Tab gone or relay down — full cleanup. - void detachTab(tabId, reason) - } - } catch (err) { - console.warn(`Failed to re-attach tab ${tabId} after navigation:`, err.message) - void detachTab(tabId, reason) - } - }, 500) + let tabInfo + try { + tabInfo = await chrome.tabs.get(tabId) + } catch { + void detachTab(tabId, reason) return } - // Non-navigation detach (user action, crash, etc.) — full cleanup. - void detachTab(tabId, reason) + if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) { + void detachTab(tabId, reason) + return + } + + if (reattachPending.has(tabId)) return + + const oldTab = tabs.get(tabId) + const oldSessionId = oldTab?.sessionId + const oldTargetId = oldTab?.targetId + + if (oldSessionId) tabBySession.delete(oldSessionId) + tabs.delete(tabId) + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) childSessionToTab.delete(childSessionId) + } + + if (oldSessionId && oldTargetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' }, + }, + }) + } catch { + // Relay may be down. + } + } + + reattachPending.add(tabId) + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attaching after navigation…', + }) + + const delays = [300, 700, 1500] + for (let attempt = 0; attempt < delays.length; attempt++) { + await new Promise((r) => setTimeout(r, delays[attempt])) + + if (!reattachPending.has(tabId)) return + + try { + await chrome.tabs.get(tabId) + } catch { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + return + } + + if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { + reattachPending.delete(tabId) + setBadge(tabId, 'error') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: relay disconnected during re-attach', + }) + return + } + + try { + await attachTab(tabId) + reattachPending.delete(tabId) + return + } catch { + // continue retries + } + } + + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attach failed (click to retry)', + }) } // Tab lifecycle listeners — clean up stale entries. chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => { + reattachPending.delete(tabId) if (!tabs.has(tabId)) return const tab = tabs.get(tabId) if (tab?.sessionId) tabBySession.delete(tab.sessionId) From 7c028e8c09bbc380e6513444e7a174697a9cb83a Mon Sep 17 00:00:00 2001 From: NK Date: Tue, 17 Feb 2026 22:04:16 -0800 Subject: [PATCH 073/408] fix: respect canceled_by_user and replaced_with_devtools detach reasons Skip re-attach when user explicitly dismisses debugger bar or opens DevTools. Prevents frustrating re-attach loop that fights user intent. Addresses review feedback from greptile-apps. --- assets/chrome-extension/background.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index 294d9f87b2b7..5ebe4008af32 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -653,15 +653,18 @@ async function onDebuggerDetach(source, reason) { if (!tabId) return if (!tabs.has(tabId)) return + // User explicitly cancelled or DevTools replaced the connection — respect their intent if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') { void detachTab(tabId, reason) return } + // Check if tab still exists — distinguishes navigation from tab close let tabInfo try { tabInfo = await chrome.tabs.get(tabId) } catch { + // Tab is gone (closed) — normal cleanup void detachTab(tabId, reason) return } From 004a61056c522de336fa5f0792988af390eafaef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:11:01 +0000 Subject: [PATCH 074/408] docs(changelog): note relay nav auto-reattach fix (#19766) (thanks @nishantkabra77) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da864a8531e8..9307e7437fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/Browser control: load `src/browser/server.js` during browser-control startup so the control listener starts reliably when browser control is enabled. (#23974) Thanks @ieaves. +- Browser/Chrome relay: harden debugger detach handling during full-page navigation with bounded auto-reattach retries and better cancellation behavior for user/devtools detaches. (#19766) Thanks @nishantkabra77. - Onboarding/Custom providers: raise verification probe token budgets for OpenAI and Anthropic compatibility checks to avoid false negatives on strict provider defaults. (#24743) Thanks @Glucksberg. - WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. - Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. From 3eabd538980e6695b0d58e0be565a5a56efc6658 Mon Sep 17 00:00:00 2001 From: Sahil Satralkar <62758655+sahilsatralkar@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:50:45 +0530 Subject: [PATCH 075/408] Tests: add regressions for subagent completion fallback and explicit direct route --- src/agents/subagent-announce.format.test.ts | 71 +++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/agents/subagent-announce.format.test.ts b/src/agents/subagent-announce.format.test.ts index a612e9fca023..b486dff75c81 100644 --- a/src/agents/subagent-announce.format.test.ts +++ b/src/agents/subagent-announce.format.test.ts @@ -993,6 +993,77 @@ describe("subagent announce formatting", () => { }); }); + it("falls back to internal requester-session injection when completion route is missing", async () => { + embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + sessionStore = { + "agent:main:main": { + sessionId: "requester-session-no-route", + }, + }; + agentSpy.mockImplementationOnce(async (req: AgentCallRequest) => { + const deliver = req.params?.deliver; + const channel = req.params?.channel; + if (deliver === true && typeof channel !== "string") { + throw new Error("Channel is required when deliver=true"); + } + return { runId: "run-main", status: "ok" }; + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-completion-missing-route", + requesterSessionKey: "main", + requesterDisplayKey: "main", + expectsCompletionMessage: true, + ...defaultOutcomeAnnounce, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(0); + expect(agentSpy).toHaveBeenCalledTimes(1); + expect(agentSpy.mock.calls[0]?.[0]).toMatchObject({ + method: "agent", + params: { + sessionKey: "agent:main:main", + deliver: false, + }, + }); + }); + + it("uses direct completion delivery when explicit channel+to route is available", async () => { + sessionStore = { + "agent:main:main": { + sessionId: "requester-session-direct-route", + }, + }; + agentSpy.mockImplementationOnce(async () => { + throw new Error("agent fallback should not run when direct route exists"); + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-completion-explicit-route", + requesterSessionKey: "main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" }, + expectsCompletionMessage: true, + ...defaultOutcomeAnnounce, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(agentSpy).toHaveBeenCalledTimes(0); + expect(sendSpy.mock.calls[0]?.[0]).toMatchObject({ + method: "send", + params: { + sessionKey: "agent:main:main", + channel: "discord", + to: "channel:12345", + }, + }); + }); + it("returns failure for completion-mode when direct delivery fails and queue fallback is unavailable", async () => { embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); From 28d658e178fed3ece5225b8465291fb1db1ca1f2 Mon Sep 17 00:00:00 2001 From: Sahil Satralkar <62758655+sahilsatralkar@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:51:18 +0530 Subject: [PATCH 076/408] Tests: verify tools invoke propagates route headers for subagent spawn context --- src/gateway/tools-invoke-http.test.ts | 44 +++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index 3a2ec73607b5..f87f00593a0a 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -5,6 +5,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890"; let cfg: Record = {}; +let lastCreateOpenClawToolsContext: Record | undefined; // Perf: keep this suite pure unit. Mock heavyweight config/session modules. vi.mock("../config/config.js", () => ({ @@ -78,7 +79,13 @@ vi.mock("../agents/openclaw-tools.js", () => { { name: "sessions_spawn", parameters: { type: "object", properties: {} }, - execute: async () => ({ ok: true }), + execute: async () => ({ + ok: true, + route: { + agentTo: lastCreateOpenClawToolsContext?.agentTo, + agentThreadId: lastCreateOpenClawToolsContext?.agentThreadId, + }, + }), }, { name: "sessions_send", @@ -119,7 +126,10 @@ vi.mock("../agents/openclaw-tools.js", () => { ]; return { - createOpenClawTools: () => tools, + createOpenClawTools: (ctx: Record) => { + lastCreateOpenClawToolsContext = ctx; + return tools; + }, }; }); @@ -176,6 +186,7 @@ beforeEach(() => { delete process.env.OPENCLAW_GATEWAY_PASSWORD; pluginHttpHandlers = []; cfg = {}; + lastCreateOpenClawToolsContext = undefined; }); const resolveGatewayToken = (): string => TEST_GATEWAY_TOKEN; @@ -365,6 +376,35 @@ describe("POST /tools/invoke", () => { expect(body.error.type).toBe("not_found"); }); + it("propagates message target/thread headers into tools context for sessions_spawn", async () => { + cfg = { + ...cfg, + agents: { + list: [{ id: "main", default: true, tools: { allow: ["sessions_spawn"] } }], + }, + gateway: { tools: { allow: ["sessions_spawn"] } }, + }; + + const res = await invokeTool({ + port: sharedPort, + headers: { + ...gatewayAuthHeaders(), + "x-openclaw-message-to": "channel:24514", + "x-openclaw-thread-id": "thread-24514", + }, + tool: "sessions_spawn", + sessionKey: "main", + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.result?.route).toEqual({ + agentTo: "channel:24514", + agentThreadId: "thread-24514", + }); + }); + it("denies sessions_send via HTTP gateway", async () => { cfg = { ...cfg, From f9ffd41cfac57ce6bcaf7b2262437b2ddf79c90a Mon Sep 17 00:00:00 2001 From: Sahil Satralkar <62758655+sahilsatralkar@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:51:28 +0530 Subject: [PATCH 077/408] Subagents: fallback completion announce to internal session when outbound route is incomplete --- src/agents/subagent-announce.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index b794824ebae6..27176029fc41 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -731,6 +731,16 @@ async function sendSubagentAnnounceDirectly(params: { } const directOrigin = normalizeDeliveryContext(params.directOrigin); + const directChannelRaw = + typeof directOrigin?.channel === "string" ? directOrigin.channel.trim() : ""; + const directChannel = + directChannelRaw && isDeliverableMessageChannel(directChannelRaw) ? directChannelRaw : ""; + const directTo = typeof directOrigin?.to === "string" ? directOrigin.to.trim() : ""; + const hasDeliverableDirectTarget = + !params.requesterIsSubagent && Boolean(directChannel) && Boolean(directTo); + const shouldDeliverExternally = + !params.requesterIsSubagent && + (!params.expectsCompletionMessage || hasDeliverableDirectTarget); const threadId = directOrigin?.threadId != null && directOrigin.threadId !== "" ? String(directOrigin.threadId) @@ -746,12 +756,12 @@ async function sendSubagentAnnounceDirectly(params: { params: { sessionKey: canonicalRequesterSessionKey, message: params.triggerMessage, - deliver: !params.requesterIsSubagent, + deliver: shouldDeliverExternally, bestEffortDeliver: params.bestEffortDeliver, - channel: params.requesterIsSubagent ? undefined : directOrigin?.channel, - accountId: params.requesterIsSubagent ? undefined : directOrigin?.accountId, - to: params.requesterIsSubagent ? undefined : directOrigin?.to, - threadId: params.requesterIsSubagent ? undefined : threadId, + channel: shouldDeliverExternally ? directChannel : undefined, + accountId: shouldDeliverExternally ? directOrigin?.accountId : undefined, + to: shouldDeliverExternally ? directTo : undefined, + threadId: shouldDeliverExternally ? threadId : undefined, idempotencyKey: params.directIdempotencyKey, }, expectFinal: true, From 8796c78b3d64fc932331bd3bdee0a1bf8d5c79a3 Mon Sep 17 00:00:00 2001 From: Sahil Satralkar <62758655+sahilsatralkar@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:51:59 +0530 Subject: [PATCH 078/408] Gateway: propagate message target and thread headers into tools invoke context --- src/gateway/tools-invoke-http.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 0be53d5fc4e6..caf71c56c3ca 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -213,6 +213,8 @@ export async function handleToolsInvokeHttpRequest( getHeader(req, "x-openclaw-message-channel") ?? "", ); const accountId = getHeader(req, "x-openclaw-account-id")?.trim() || undefined; + const agentTo = getHeader(req, "x-openclaw-message-to")?.trim() || undefined; + const agentThreadId = getHeader(req, "x-openclaw-thread-id")?.trim() || undefined; const { agentId, @@ -248,6 +250,8 @@ export async function handleToolsInvokeHttpRequest( agentSessionKey: sessionKey, agentChannel: messageChannel ?? undefined, agentAccountId: accountId, + agentTo, + agentThreadId, config: cfg, pluginToolAllowlist: collectExplicitAllowlist([ profilePolicy, From 420d8c663c6cc4994a1c9cbcd48c3ddcc2f884f0 Mon Sep 17 00:00:00 2001 From: Sahil Satralkar <62758655+sahilsatralkar@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:52:16 +0530 Subject: [PATCH 079/408] Tests/Typing: stabilize subagent completion routing changes --- ...aw-tools.subagents.sessions-spawn.lifecycle.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index 5a883c7c6c4e..77b948ea5af5 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -245,7 +245,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { } | undefined; expect(second?.sessionKey).toBe("agent:main:discord:group:req"); - expect(second?.deliver).toBe(true); + expect(second?.deliver).toBe(false); expect(second?.message).toContain("subagent task"); const sendCalls = ctx.calls.filter((c) => c.method === "send"); @@ -297,7 +297,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { // Second call: main agent trigger const second = agentCalls[1]?.params as { sessionKey?: string; deliver?: boolean } | undefined; expect(second?.sessionKey).toBe("agent:main:discord:group:req"); - expect(second?.deliver).toBe(true); + expect(second?.deliver).toBe(false); // No direct send to external channel (main agent handles delivery) const sendCalls = ctx.calls.filter((c) => c.method === "send"); @@ -365,8 +365,8 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { const announceParams = agentCalls[1]?.params as | { accountId?: string; channel?: string; deliver?: boolean } | undefined; - expect(announceParams?.deliver).toBe(true); - expect(announceParams?.channel).toBe("whatsapp"); - expect(announceParams?.accountId).toBe("kev"); + expect(announceParams?.deliver).toBe(false); + expect(announceParams?.channel).toBeUndefined(); + expect(announceParams?.accountId).toBeUndefined(); }); }); From b2719d00ff6c92a3b0b4841dc8d7e0270d4a242c Mon Sep 17 00:00:00 2001 From: Keith Date: Sun, 22 Feb 2026 17:06:06 +1300 Subject: [PATCH 080/408] fix(subagents): restore isInternalMessageChannel guard in resolveAnnounceOrigin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the narrower internal-channel guard from PR #22223 (fe57bea08) that was inadvertently reverted by f555835b0. The original !isDeliverableMessageChannel() check strips the requester's channel whenever it is not in the registered deliverable set. This causes delivery failures for plugin channels whose adapter ID differs from their plugin ID (e.g. "gmail" vs "openclaw-gmail"): the requester origin is discarded and the announce falls back to stale session routes — typically WhatsApp — resulting in a timeout followed by an E.164 format error. Replacing with isInternalMessageChannel() limits stripping to explicitly internal channels (webchat), preserving the requester origin for all external channels regardless of whether they are currently in the deliverable list. Fixes: #22223 regression introduced in f555835b0 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/agents/subagent-announce.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 27176029fc41..c0c981e8e3fc 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -21,7 +21,7 @@ import { mergeDeliveryContext, normalizeDeliveryContext, } from "../utils/delivery-context.js"; -import { isDeliverableMessageChannel } from "../utils/message-channel.js"; +import { isDeliverableMessageChannel, isInternalMessageChannel } from "../utils/message-channel.js"; import { buildAnnounceIdFromChildRun, buildAnnounceIdempotencyKey, @@ -350,9 +350,12 @@ function resolveAnnounceOrigin( ): DeliveryContext | undefined { const normalizedRequester = normalizeDeliveryContext(requesterOrigin); const normalizedEntry = deliveryContextFromSession(entry); - if (normalizedRequester?.channel && !isDeliverableMessageChannel(normalizedRequester.channel)) { - // Ignore internal/non-deliverable channel hints (for example webchat) - // so a valid persisted route can still be used for outbound delivery. + if (normalizedRequester?.channel && isInternalMessageChannel(normalizedRequester.channel)) { + // Ignore internal channel hints (webchat) so a valid persisted route + // can still be used for outbound delivery. Non-standard channels that + // are not in the deliverable list should NOT be stripped here — doing + // so causes the session entry's stale lastChannel (often WhatsApp) to + // override the actual requester origin, leading to delivery failures. return mergeDeliveryContext( { accountId: normalizedRequester.accountId, From 1fdaaaedd36410f43437821941a16fa213eef831 Mon Sep 17 00:00:00 2001 From: Kriz Poon Date: Fri, 20 Feb 2026 14:09:37 +0000 Subject: [PATCH 081/408] Docs: clarify Chrome extension relay port derivation (gateway + 3) --- docs/tools/chrome-extension.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index 6049dfb36a73..964eb40f37b5 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -77,6 +77,18 @@ openclaw browser create-profile \ --color "#00AA00" ``` +### Custom Gateway ports + +If you're using a custom gateway port, the extension relay port is automatically derived: + +**Extension Relay Port = Gateway Port + 3** + +Example: if `gateway.port: 19001`, then: + +- Extension relay port: `19004` (gateway + 3) + +Configure the extension to use the derived relay port in the extension Options page. + ## Attach / detach (toolbar button) - Open the tab you want OpenClaw to control. From 0a53a77dd6f29bf8c65ec030b6282d45b013da4f Mon Sep 17 00:00:00 2001 From: Kriz Poon Date: Fri, 20 Feb 2026 15:31:17 +0000 Subject: [PATCH 082/408] Chrome extension: validate relay endpoint response format Options page now validates that /json/version returns valid CDP JSON (with Browser/Protocol-Version fields) rather than accepting any HTTP 200 response. This prevents false success when users mistakenly configure the gateway port instead of the relay port (gateway + 3). Helpful error messages now guide users to use "gateway port + 3" when they configure the wrong port. --- assets/chrome-extension/background.js | 13 +++++++++- assets/chrome-extension/options.js | 37 +++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index 5ebe4008af32..94956571101e 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -882,7 +882,18 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { const { url, token } = msg const headers = token ? { 'x-openclaw-relay-token': token } : {} fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) }) - .then((res) => sendResponse({ status: res.status, ok: res.ok })) + .then(async (res) => { + const contentType = String(res.headers.get('content-type') || '') + let json = null + if (contentType.includes('application/json')) { + try { + json = await res.json() + } catch { + json = null + } + } + sendResponse({ status: res.status, ok: res.ok, contentType, json }) + }) .catch((err) => sendResponse({ status: 0, ok: false, error: String(err) })) return true }) diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js index 7a47a5d947e7..96b87768dae9 100644 --- a/assets/chrome-extension/options.js +++ b/assets/chrome-extension/options.js @@ -54,12 +54,39 @@ async function checkRelayReachable(port, token) { } if (res.error) throw new Error(res.error) if (!res.ok) throw new Error(`HTTP ${res.status}`) + + // Validate that this is a CDP relay /json/version payload, not gateway HTML. + const contentType = String(res.contentType || '') + const data = res.json + if (!contentType.includes('application/json')) { + setStatus( + 'error', + 'Wrong port: this is likely the gateway, not the relay. Use gateway port + 3 (for gateway 18789, relay is 18792).', + ) + return + } + if (!data || typeof data !== 'object' || !('Browser' in data) || !('Protocol-Version' in data)) { + setStatus( + 'error', + 'Wrong port: expected relay /json/version response. Use gateway port + 3 (for gateway 18789, relay is 18792).', + ) + return + } + setStatus('ok', `Relay reachable and authenticated at http://127.0.0.1:${port}/`) - } catch { - setStatus( - 'error', - `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, - ) + } catch (err) { + const message = String(err || '').toLowerCase() + if (message.includes('json') || message.includes('syntax')) { + setStatus( + 'error', + 'Wrong port: this is not a relay endpoint. Use gateway port + 3 (for gateway 18789, relay is 18792).', + ) + } else { + setStatus( + 'error', + `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, + ) + } } } From b7949d317fb2bdec77420e61d0d5818d303134f3 Mon Sep 17 00:00:00 2001 From: Kriz Poon Date: Fri, 20 Feb 2026 23:28:15 +0000 Subject: [PATCH 083/408] Chrome extension: simplify validation logic Use OR operator to require both Browser and Protocol-Version fields. Simplified catch block to generic error message since specific wrong-port cases are already handled by the validation blocks above. --- assets/chrome-extension/options.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js index 96b87768dae9..d2d9a198a3b3 100644 --- a/assets/chrome-extension/options.js +++ b/assets/chrome-extension/options.js @@ -87,6 +87,19 @@ async function checkRelayReachable(port, token) { `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, ) } + } catch (err) { + const message = String(err || '').toLowerCase() + if (message.includes('json') || message.includes('syntax')) { + setStatus( + 'error', + 'Wrong port: this is not a relay endpoint. Use gateway port + 3 (for gateway 18789, relay is 18792).', + ) + } else { + setStatus( + 'error', + `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, + ) + } } } From 1237516ae892847bacef95d835b1743f9d66b3f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:14:43 +0000 Subject: [PATCH 084/408] fix(chrome-extension): finalize relay endpoint validation flow (#22252) (thanks @krizpoon) --- CHANGELOG.md | 1 + assets/chrome-extension/options.js | 13 ------------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9307e7437fd2..358264587da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Gateway/Browser control: load `src/browser/server.js` during browser-control startup so the control listener starts reliably when browser control is enabled. (#23974) Thanks @ieaves. - Browser/Chrome relay: harden debugger detach handling during full-page navigation with bounded auto-reattach retries and better cancellation behavior for user/devtools detaches. (#19766) Thanks @nishantkabra77. +- Browser/Chrome extension options: validate relay `/json/version` payload shape and content type (not just HTTP status) to detect wrong-port gateway checks, and clarify relay port derivation for custom gateway ports (`gateway + 3`). (#22252) Thanks @krizpoon. - Onboarding/Custom providers: raise verification probe token budgets for OpenAI and Anthropic compatibility checks to avoid false negatives on strict provider defaults. (#24743) Thanks @Glucksberg. - WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. - Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js index d2d9a198a3b3..96b87768dae9 100644 --- a/assets/chrome-extension/options.js +++ b/assets/chrome-extension/options.js @@ -87,19 +87,6 @@ async function checkRelayReachable(port, token) { `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, ) } - } catch (err) { - const message = String(err || '').toLowerCase() - if (message.includes('json') || message.includes('syntax')) { - setStatus( - 'error', - 'Wrong port: this is not a relay endpoint. Use gateway port + 3 (for gateway 18789, relay is 18792).', - ) - } else { - setStatus( - 'error', - `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, - ) - } } } From f6b4baa776d4b0450ca444ebf8e6f2773b9a256c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:16:17 +0000 Subject: [PATCH 085/408] test(telegram): align stop-phrase sequential key expectation (#25034) --- src/telegram/bot.create-telegram-bot.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index fdd2eb32ecc4..f5c4735ea758 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -183,7 +183,7 @@ describe("createTelegramBot", () => { getTelegramSequentialKey({ message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop please" }), }), - ).toBe("telegram:123"); + ).toBe("telegram:123:control"); expect( getTelegramSequentialKey({ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort" }), From 87dd89696357d02ec0f36b1ef90b44732890395a Mon Sep 17 00:00:00 2001 From: Slats <42514321+Slats24@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:38:20 +0000 Subject: [PATCH 086/408] fix: sessions_sspawn model override ignored for sub-agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix bug where sessions_spawn model parameter was ignored, causing sub-agents to always use the parent's default model. The allowAny flag from buildAllowedModelSet() was not being captured or used. 🤖 AI-assisted (Claude) - fully tested locally Fixes #17479, #6295, #10963 --- src/commands/agent.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 7ca8591faa40..ca4e42d314b8 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -390,6 +390,7 @@ export async function agentCommand( let allowedModelKeys = new Set(); let allowedModelCatalog: Awaited> = []; let modelCatalog: Awaited> | null = null; + let allowAnyModel = false; if (needsModelCatalog) { modelCatalog = await loadModelCatalog({ config: cfg }); @@ -401,6 +402,7 @@ export async function agentCommand( }); allowedModelKeys = allowed.allowedKeys; allowedModelCatalog = allowed.allowedCatalog; + allowAnyModel = allowed.allowAny ?? false; } if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) { @@ -412,7 +414,7 @@ export async function agentCommand( const key = modelKey(normalizedOverride.provider, normalizedOverride.model); if ( !isCliProvider(normalizedOverride.provider, cfg) && - allowedModelKeys.size > 0 && + !allowAnyModel && !allowedModelKeys.has(key) ) { const { updated } = applyModelOverrideToSessionEntry({ @@ -439,7 +441,7 @@ export async function agentCommand( const key = modelKey(normalizedStored.provider, normalizedStored.model); if ( isCliProvider(normalizedStored.provider, cfg) || - allowedModelKeys.size === 0 || + allowAnyModel || allowedModelKeys.has(key) ) { provider = normalizedStored.provider; From cd3927ad67ae9328eac067930e068c2b8bd06dec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:15:18 +0000 Subject: [PATCH 087/408] fix(sessions): preserve allow-any subagent model overrides (#21088) (thanks @Slats24) --- CHANGELOG.md | 1 + src/commands/agent.test.ts | 42 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 358264587da9..008661d60eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai - Infra/Windows TOCTOU: handle Windows `dev=0` edge cases in same-file identity checks. (#24939) - Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) - Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) +- Sessions/Model overrides: keep stored sub-agent model overrides when `agents.defaults.models` is empty (allow-any mode) instead of resetting to defaults. (#21088) Thanks @Slats24. - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg. - WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 3e26ec3ec000..0118e076365b 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -367,6 +367,48 @@ describe("agentCommand", () => { }); }); + it("keeps stored session model override when models allowlist is empty", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + writeSessionStoreSeed(store, { + "agent:main:subagent:allow-any": { + sessionId: "session-allow-any", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: "gpt-custom-foo", + }, + }); + + mockConfig(home, store, { + model: { primary: "anthropic/claude-opus-4-5" }, + models: {}, + }); + + vi.mocked(loadModelCatalog).mockResolvedValueOnce([ + { id: "claude-opus-4-5", name: "Opus", provider: "anthropic" }, + ]); + + await agentCommand( + { + message: "hi", + sessionKey: "agent:main:subagent:allow-any", + }, + runtime, + ); + + const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; + expect(callArgs?.provider).toBe("openai"); + expect(callArgs?.model).toBe("gpt-custom-foo"); + + const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< + string, + { providerOverride?: string; modelOverride?: string } + >; + expect(saved["agent:main:subagent:allow-any"]?.providerOverride).toBe("openai"); + expect(saved["agent:main:subagent:allow-any"]?.modelOverride).toBe("gpt-custom-foo"); + }); + }); + it("keeps explicit sessionKey even when sessionId exists elsewhere", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); From c3b3065cc9346e3290d0ae60078237c65d3a02bf Mon Sep 17 00:00:00 2001 From: HeMuling <74801533+HeMuling@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:42:22 +0800 Subject: [PATCH 088/408] fix(subagents): reconcile orphaned restored runs --- ...agent-registry.announce-loop-guard.test.ts | 6 +- .../subagent-registry.persistence.test.ts | 152 +++++++++++++++++- src/agents/subagent-registry.ts | 140 ++++++++++++++++ 3 files changed, 296 insertions(+), 2 deletions(-) diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts index 5a2bfb2dbecb..8389c53503c6 100644 --- a/src/agents/subagent-registry.announce-loop-guard.test.ts +++ b/src/agents/subagent-registry.announce-loop-guard.test.ts @@ -16,7 +16,11 @@ vi.mock("../config/config.js", () => ({ })); vi.mock("../config/sessions.js", () => ({ - loadSessionStore: () => ({}), + loadSessionStore: () => ({ + "agent:main:subagent:child-1": { sessionId: "sess-child-1", updatedAt: 1 }, + "agent:main:subagent:expired-child": { sessionId: "sess-expired", updatedAt: 1 }, + "agent:main:subagent:retry-budget": { sessionId: "sess-retry", updatedAt: 1 }, + }), resolveAgentIdFromSessionKey: (key: string) => { const match = key.match(/^agent:([^:]+)/); return match?.[1] ?? "main"; diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index 9ef2458e35c8..5558d77785e3 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -5,7 +5,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import "./subagent-registry.mocks.shared.js"; import { captureEnv } from "../test-utils/env.js"; import { + addSubagentRunForTests, + clearSubagentRunSteerRestart, initSubagentRegistry, + listSubagentRunsForRequester, registerSubagentRun, resetSubagentRegistryForTests, } from "./subagent-registry.js"; @@ -22,12 +25,93 @@ describe("subagent registry persistence", () => { const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); let tempStateDir: string | null = null; - const writePersistedRegistry = async (persisted: Record) => { + const resolveAgentIdFromSessionKey = (sessionKey: string) => { + const match = sessionKey.match(/^agent:([^:]+):/i); + return (match?.[1] ?? "main").trim().toLowerCase() || "main"; + }; + + const resolveSessionStorePath = (stateDir: string, agentId: string) => + path.join(stateDir, "agents", agentId, "sessions", "sessions.json"); + + const readSessionStore = async (storePath: string) => { + try { + const raw = await fs.readFile(storePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record>; + } + } catch { + // ignore + } + return {} as Record>; + }; + + const writeChildSessionEntry = async (params: { + sessionKey: string; + sessionId?: string; + updatedAt?: number; + }) => { + if (!tempStateDir) { + throw new Error("tempStateDir not initialized"); + } + const agentId = resolveAgentIdFromSessionKey(params.sessionKey); + const storePath = resolveSessionStorePath(tempStateDir, agentId); + const store = await readSessionStore(storePath); + store[params.sessionKey] = { + ...(store[params.sessionKey] ?? {}), + sessionId: params.sessionId ?? `sess-${agentId}-${Date.now()}`, + updatedAt: params.updatedAt ?? Date.now(), + }; + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, `${JSON.stringify(store)}\n`, "utf8"); + return storePath; + }; + + const removeChildSessionEntry = async (sessionKey: string) => { + if (!tempStateDir) { + throw new Error("tempStateDir not initialized"); + } + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const storePath = resolveSessionStorePath(tempStateDir, agentId); + const store = await readSessionStore(storePath); + delete store[sessionKey]; + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, `${JSON.stringify(store)}\n`, "utf8"); + return storePath; + }; + + const seedChildSessionsForPersistedRuns = async (persisted: Record) => { + const runs = (persisted.runs ?? {}) as Record< + string, + { + runId?: string; + childSessionKey?: string; + } + >; + for (const [runId, run] of Object.entries(runs)) { + const childSessionKey = run?.childSessionKey?.trim(); + if (!childSessionKey) { + continue; + } + await writeChildSessionEntry({ + sessionKey: childSessionKey, + sessionId: `sess-${run.runId ?? runId}`, + }); + } + }; + + const writePersistedRegistry = async ( + persisted: Record, + opts?: { seedChildSessions?: boolean }, + ) => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; const registryPath = path.join(tempStateDir, "subagents", "runs.json"); await fs.mkdir(path.dirname(registryPath), { recursive: true }); await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8"); + if (opts?.seedChildSessions !== false) { + await seedChildSessionsForPersistedRuns(persisted); + } return registryPath; }; @@ -90,6 +174,10 @@ describe("subagent registry persistence", () => { task: "do the thing", cleanup: "keep", }); + await writeChildSessionEntry({ + sessionKey: "agent:main:subagent:test", + sessionId: "sess-test", + }); const registryPath = path.join(tempStateDir, "subagents", "runs.json"); const raw = await fs.readFile(registryPath, "utf8"); @@ -162,6 +250,10 @@ describe("subagent registry persistence", () => { }; await fs.mkdir(path.dirname(registryPath), { recursive: true }); await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8"); + await writeChildSessionEntry({ + sessionKey: "agent:main:subagent:two", + sessionId: "sess-two", + }); resetSubagentRegistryForTests({ persist: false }); initSubagentRegistry(); @@ -268,6 +360,64 @@ describe("subagent registry persistence", () => { expect(afterSecond.runs?.["run-4"]).toBeUndefined(); }); + it("reconciles orphaned restored runs by pruning them from registry", async () => { + const persisted = createPersistedEndedRun({ + runId: "run-orphan-restore", + childSessionKey: "agent:main:subagent:ghost-restore", + task: "orphan restore", + cleanup: "keep", + }); + const registryPath = await writePersistedRegistry(persisted, { + seedChildSessions: false, + }); + + await restartRegistryAndFlush(); + + expect(announceSpy).not.toHaveBeenCalled(); + const after = JSON.parse(await fs.readFile(registryPath, "utf8")) as { + runs?: Record; + }; + expect(after.runs?.["run-orphan-restore"]).toBeUndefined(); + expect(listSubagentRunsForRequester("agent:main:main")).toHaveLength(0); + }); + + it("resume guard prunes orphan runs before announce retry", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-")); + process.env.OPENCLAW_STATE_DIR = tempStateDir; + const runId = "run-orphan-resume-guard"; + const childSessionKey = "agent:main:subagent:ghost-resume"; + const now = Date.now(); + + await writeChildSessionEntry({ + sessionKey: childSessionKey, + sessionId: "sess-resume-guard", + updatedAt: now, + }); + addSubagentRunForTests({ + runId, + childSessionKey, + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "resume orphan guard", + cleanup: "keep", + createdAt: now - 50, + startedAt: now - 25, + endedAt: now, + suppressAnnounceReason: "steer-restart", + cleanupHandled: false, + }); + await removeChildSessionEntry(childSessionKey); + + const changed = clearSubagentRunSteerRestart(runId); + expect(changed).toBe(true); + await flushQueuedRegistryWork(); + + expect(announceSpy).not.toHaveBeenCalled(); + expect(listSubagentRunsForRequester("agent:main:main")).toHaveLength(0); + const persisted = loadSubagentRegistryFromDisk(); + expect(persisted.has(runId)).toBe(false); + }); + it("uses isolated temp state when OPENCLAW_STATE_DIR is unset in tests", async () => { delete process.env.OPENCLAW_STATE_DIR; vi.resetModules(); diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 8506b77d53e4..edb8f228b07b 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -1,4 +1,10 @@ import { loadConfig } from "../config/config.js"; +import { + loadSessionStore, + resolveAgentIdFromSessionKey, + resolveStorePath, + type SessionEntry, +} from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; import { onAgentEvent } from "../infra/agent-events.js"; import { defaultRuntime } from "../runtime.js"; @@ -59,6 +65,7 @@ const MAX_ANNOUNCE_RETRY_COUNT = 3; * succeeded. Guards against stale registry entries surviving gateway restarts. */ const ANNOUNCE_EXPIRY_MS = 5 * 60_000; // 5 minutes +type SubagentRunOrphanReason = "missing-session-entry" | "missing-session-id"; function resolveAnnounceRetryDelayMs(retryCount: number) { const boundedRetryCount = Math.max(0, Math.min(retryCount, 10)); @@ -82,6 +89,119 @@ function persistSubagentRuns() { persistSubagentRunsToDisk(subagentRuns); } +function findSessionEntryByKey(store: Record, sessionKey: string) { + const direct = store[sessionKey]; + if (direct) { + return direct; + } + const normalized = sessionKey.toLowerCase(); + for (const [key, entry] of Object.entries(store)) { + if (key.toLowerCase() === normalized) { + return entry; + } + } + return undefined; +} + +function resolveSubagentRunOrphanReason(params: { + entry: SubagentRunRecord; + storeCache?: Map>; +}): SubagentRunOrphanReason | null { + const childSessionKey = params.entry.childSessionKey?.trim(); + if (!childSessionKey) { + return "missing-session-entry"; + } + try { + const cfg = loadConfig(); + const agentId = resolveAgentIdFromSessionKey(childSessionKey); + const storePath = resolveStorePath(cfg.session?.store, { agentId }); + let store = params.storeCache?.get(storePath); + if (!store) { + store = loadSessionStore(storePath); + params.storeCache?.set(storePath, store); + } + const sessionEntry = findSessionEntryByKey(store, childSessionKey); + if (!sessionEntry) { + return "missing-session-entry"; + } + if (typeof sessionEntry.sessionId !== "string" || !sessionEntry.sessionId.trim()) { + return "missing-session-id"; + } + return null; + } catch { + // Best-effort guard: avoid false orphan pruning on transient read/config failures. + return null; + } +} + +function reconcileOrphanedRun(params: { + runId: string; + entry: SubagentRunRecord; + reason: SubagentRunOrphanReason; + source: "restore" | "resume"; +}) { + const now = Date.now(); + let changed = false; + if (typeof params.entry.endedAt !== "number") { + params.entry.endedAt = now; + changed = true; + } + const orphanOutcome: SubagentRunOutcome = { + status: "error", + error: `orphaned subagent run (${params.reason})`, + }; + if (!runOutcomesEqual(params.entry.outcome, orphanOutcome)) { + params.entry.outcome = orphanOutcome; + changed = true; + } + if (params.entry.endedReason !== SUBAGENT_ENDED_REASON_ERROR) { + params.entry.endedReason = SUBAGENT_ENDED_REASON_ERROR; + changed = true; + } + if (params.entry.cleanupHandled !== true) { + params.entry.cleanupHandled = true; + changed = true; + } + if (typeof params.entry.cleanupCompletedAt !== "number") { + params.entry.cleanupCompletedAt = now; + changed = true; + } + const removed = subagentRuns.delete(params.runId); + resumedRuns.delete(params.runId); + if (!removed && !changed) { + return false; + } + defaultRuntime.log( + `[warn] Subagent orphan run pruned source=${params.source} run=${params.runId} child=${params.entry.childSessionKey} reason=${params.reason}`, + ); + return true; +} + +function reconcileOrphanedRestoredRuns() { + const storeCache = new Map>(); + let changed = false; + for (const [runId, entry] of subagentRuns.entries()) { + const orphanReason = resolveSubagentRunOrphanReason({ + entry, + storeCache, + }); + if (!orphanReason) { + continue; + } + if ( + reconcileOrphanedRun({ + runId, + entry, + reason: orphanReason, + source: "restore", + }) + ) { + changed = true; + } + } + return changed; +} + const resumedRuns = new Set(); const endedHookInFlightRunIds = new Set(); @@ -225,6 +345,20 @@ function resumeSubagentRun(runId: string) { if (!entry) { return; } + const orphanReason = resolveSubagentRunOrphanReason({ entry }); + if (orphanReason) { + if ( + reconcileOrphanedRun({ + runId, + entry, + reason: orphanReason, + source: "resume", + }) + ) { + persistSubagentRuns(); + } + return; + } if (entry.cleanupCompletedAt) { return; } @@ -290,6 +424,12 @@ function restoreSubagentRunsOnce() { if (restoredCount === 0) { return; } + if (reconcileOrphanedRestoredRuns()) { + persistSubagentRuns(); + } + if (subagentRuns.size === 0) { + return; + } // Resume pending work. ensureListener(); if ([...subagentRuns.values()].some((entry) => entry.archiveAtMs)) { From d0e008d4601dca29011769f68b83b408953b7baa Mon Sep 17 00:00:00 2001 From: HeMuling <74801533+HeMuling@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:42:31 +0800 Subject: [PATCH 089/408] chore(status): clarify bootstrap file semantics --- .../subagent-registry.persistence.test.ts | 2 +- src/commands/status-all/report-lines.test.ts | 74 +++++++++++++++++++ src/commands/status-all/report-lines.ts | 8 +- src/commands/status.command.ts | 4 +- src/commands/status.test.ts | 1 + 5 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 src/commands/status-all/report-lines.test.ts diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index 5558d77785e3..1c3db23672fe 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -58,7 +58,7 @@ describe("subagent registry persistence", () => { const storePath = resolveSessionStorePath(tempStateDir, agentId); const store = await readSessionStore(storePath); store[params.sessionKey] = { - ...(store[params.sessionKey] ?? {}), + ...store[params.sessionKey], sessionId: params.sessionId ?? `sess-${agentId}-${Date.now()}`, updatedAt: params.updatedAt ?? Date.now(), }; diff --git a/src/commands/status-all/report-lines.test.ts b/src/commands/status-all/report-lines.test.ts new file mode 100644 index 000000000000..5769bc0d41d0 --- /dev/null +++ b/src/commands/status-all/report-lines.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ProgressReporter } from "../../cli/progress.js"; +import { buildStatusAllReportLines } from "./report-lines.js"; + +const diagnosisSpy = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("./diagnosis.js", () => ({ + appendStatusAllDiagnosis: diagnosisSpy, +})); + +describe("buildStatusAllReportLines", () => { + it("renders bootstrap column using file-presence semantics", async () => { + const progress: ProgressReporter = { + setLabel: () => {}, + setPercent: () => {}, + tick: () => {}, + done: () => {}, + }; + const lines = await buildStatusAllReportLines({ + progress, + overviewRows: [{ Item: "Gateway", Value: "ok" }], + channels: { + rows: [], + details: [], + }, + channelIssues: [], + agentStatus: { + agents: [ + { + id: "main", + bootstrapPending: true, + sessionsCount: 1, + lastActiveAgeMs: 12_000, + sessionsPath: "/tmp/main-sessions.json", + }, + { + id: "ops", + bootstrapPending: false, + sessionsCount: 0, + lastActiveAgeMs: null, + sessionsPath: "/tmp/ops-sessions.json", + }, + ], + }, + connectionDetailsForReport: "", + diagnosis: { + snap: null, + remoteUrlMissing: false, + sentinel: null, + lastErr: null, + port: 18789, + portUsage: null, + tailscaleMode: "off", + tailscale: { + backendState: null, + dnsName: null, + ips: [], + error: null, + }, + tailscaleHttpsUrl: null, + skillStatus: null, + channelsStatus: null, + channelIssues: [], + gatewayReachable: false, + health: null, + }, + }); + + const output = lines.join("\n"); + expect(output).toContain("Bootstrap file"); + expect(output).toContain("PRESENT"); + expect(output).toContain("ABSENT"); + }); +}); diff --git a/src/commands/status-all/report-lines.ts b/src/commands/status-all/report-lines.ts index 71dc035ad848..0db503002bd0 100644 --- a/src/commands/status-all/report-lines.ts +++ b/src/commands/status-all/report-lines.ts @@ -121,11 +121,11 @@ export async function buildStatusAllReportLines(params: { const agentRows = params.agentStatus.agents.map((a) => ({ Agent: a.name?.trim() ? `${a.id} (${a.name.trim()})` : a.id, - Bootstrap: + BootstrapFile: a.bootstrapPending === true - ? warn("PENDING") + ? warn("PRESENT") : a.bootstrapPending === false - ? ok("OK") + ? ok("ABSENT") : "unknown", Sessions: String(a.sessionsCount), Active: a.lastActiveAgeMs != null ? formatTimeAgo(a.lastActiveAgeMs) : "unknown", @@ -136,7 +136,7 @@ export async function buildStatusAllReportLines(params: { width: tableWidth, columns: [ { key: "Agent", header: "Agent", minWidth: 12 }, - { key: "Bootstrap", header: "Bootstrap", minWidth: 10 }, + { key: "BootstrapFile", header: "Bootstrap file", minWidth: 14 }, { key: "Sessions", header: "Sessions", align: "right", minWidth: 8 }, { key: "Active", header: "Active", minWidth: 10 }, { key: "Store", header: "Store", flex: true, minWidth: 34 }, diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index a613f0896ee2..e78faa4cc38e 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -265,8 +265,8 @@ export async function statusCommand( const agentsValue = (() => { const pending = agentStatus.bootstrapPendingCount > 0 - ? `${agentStatus.bootstrapPendingCount} bootstrapping` - : "no bootstraps"; + ? `${agentStatus.bootstrapPendingCount} bootstrap file${agentStatus.bootstrapPendingCount === 1 ? "" : "s"} present` + : "no bootstrap files"; const def = agentStatus.agents.find((a) => a.id === agentStatus.defaultId); const defActive = def?.lastActiveAgeMs != null ? formatTimeAgo(def.lastActiveAgeMs) : "unknown"; const defSuffix = def ? ` · default ${def.id} active ${defActive}` : ""; diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 4532acb3ea2c..e628d79aa7da 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -388,6 +388,7 @@ describe("statusCommand", () => { expect(logs.some((l: string) => l.includes("Memory"))).toBe(true); expect(logs.some((l: string) => l.includes("Channels"))).toBe(true); expect(logs.some((l: string) => l.includes("WhatsApp"))).toBe(true); + expect(logs.some((l: string) => l.includes("bootstrap files"))).toBe(true); expect(logs.some((l: string) => l.includes("Sessions"))).toBe(true); expect(logs.some((l: string) => l.includes("+1000"))).toBe(true); expect(logs.some((l: string) => l.includes("50%"))).toBe(true); From 3c13f4c2b4ec02200f2f780c240e22e0c0277ed0 Mon Sep 17 00:00:00 2001 From: HeMuling <74801533+HeMuling@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:26:00 +0800 Subject: [PATCH 090/408] test(subagents): mock sessions store in steer-restart coverage --- .../subagent-registry.steer-restart.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index 0eed4e055324..6a7e86100c6d 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -38,6 +38,31 @@ vi.mock("../config/config.js", () => ({ })), })); +vi.mock("../config/sessions.js", () => { + const sessionStore = new Proxy>( + {}, + { + get(target, prop, receiver) { + if (typeof prop !== "string" || prop in target) { + return Reflect.get(target, prop, receiver); + } + return { sessionId: `sess-${prop}`, updatedAt: 1 }; + }, + }, + ); + + return { + loadSessionStore: vi.fn(() => sessionStore), + resolveAgentIdFromSessionKey: (key: string) => { + const match = key.match(/^agent:([^:]+)/); + return match?.[1] ?? "main"; + }, + resolveMainSessionKey: () => "agent:main:main", + resolveStorePath: () => "/tmp/test-store", + updateSessionStore: vi.fn(), + }; +}); + const announceSpy = vi.fn(async (_params: unknown) => true); const runSubagentEndedHookMock = vi.fn(async (_event?: unknown, _ctx?: unknown) => {}); vi.mock("./subagent-announce.js", () => ({ From ffc22778f380c2181a4c377ae0ba636ac07c6284 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:17:42 +0000 Subject: [PATCH 091/408] fix(subagents): prune orphaned restored runs + status wording (#24244) (thanks @HeMuling) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 008661d60eeb..07b72149af71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai - Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) - Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) - Sessions/Model overrides: keep stored sub-agent model overrides when `agents.defaults.models` is empty (allow-any mode) instead of resetting to defaults. (#21088) Thanks @Slats24. +- Subagents/Registry: prune orphaned restored runs (missing child session/sessionId) before retry/announce resume to prevent zombie entries and stale completion retries, and clarify status output to report bootstrap-file presence semantics. (#24244) Thanks @HeMuling. - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg. - WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. From 3f5e7f815668e8764ce3f2e46eaa51f370045c84 Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Mon, 23 Feb 2026 14:43:38 -0700 Subject: [PATCH 092/408] fix(gateway): consume allow-once approvals to prevent replay (cherry picked from commit 6adacd447c61b7b743d49e8fabab37fb0b2694c5) --- src/gateway/exec-approval-manager.ts | 15 +++++ .../node-invoke-system-run-approval.test.ts | 61 ++++++++++++++++++- .../node-invoke-system-run-approval.ts | 18 +++++- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/gateway/exec-approval-manager.ts b/src/gateway/exec-approval-manager.ts index a065be1916a8..5e582d42a03a 100644 --- a/src/gateway/exec-approval-manager.ts +++ b/src/gateway/exec-approval-manager.ts @@ -154,6 +154,21 @@ export class ExecApprovalManager { return entry?.record ?? null; } + consumeAllowOnce(recordId: string): boolean { + const entry = this.pending.get(recordId); + if (!entry) { + return false; + } + const record = entry.record; + if (record.decision !== "allow-once") { + return false; + } + // One-time approvals must be consumed atomically so the same runId + // cannot be replayed during the resolved-entry grace window. + record.decision = undefined; + return true; + } + /** * Wait for decision on an already-registered approval. * Returns the decision promise if the ID is pending, null otherwise. diff --git a/src/gateway/node-invoke-system-run-approval.test.ts b/src/gateway/node-invoke-system-run-approval.test.ts index ddae856048b9..653f0d478522 100644 --- a/src/gateway/node-invoke-system-run-approval.test.ts +++ b/src/gateway/node-invoke-system-run-approval.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import type { ExecApprovalRecord } from "./exec-approval-manager.js"; +import { ExecApprovalManager, type ExecApprovalRecord } from "./exec-approval-manager.js"; import { sanitizeSystemRunParamsForForwarding } from "./node-invoke-system-run-approval.js"; describe("sanitizeSystemRunParamsForForwarding", () => { @@ -36,8 +36,17 @@ describe("sanitizeSystemRunParamsForForwarding", () => { } function manager(record: ReturnType) { + let consumed = false; return { getSnapshot: () => record, + consumeAllowOnce: () => { + if (consumed || record.decision !== "allow-once") { + return false; + } + consumed = true; + record.decision = undefined; + return true; + }, }; } @@ -130,6 +139,56 @@ describe("sanitizeSystemRunParamsForForwarding", () => { }); expectAllowOnceForwardingResult(result); }); + test("consumes allow-once approvals and blocks same runId replay", async () => { + const approvalManager = new ExecApprovalManager(); + const runId = "approval-replay-1"; + const record = approvalManager.create( + { + host: "node", + command: "echo SAFE", + cwd: null, + agentId: null, + sessionKey: null, + }, + 60_000, + runId, + ); + record.requestedByConnId = "conn-1"; + record.requestedByDeviceId = "dev-1"; + record.requestedByClientId = "cli-1"; + + const decisionPromise = approvalManager.register(record, 60_000); + approvalManager.resolve(runId, "allow-once", "operator"); + await expect(decisionPromise).resolves.toBe("allow-once"); + + const params = { + command: ["echo", "SAFE"], + rawCommand: "echo SAFE", + runId, + approved: true, + approvalDecision: "allow-once", + }; + + const first = sanitizeSystemRunParamsForForwarding({ + rawParams: params, + client, + execApprovalManager: approvalManager, + nowMs: now, + }); + expectAllowOnceForwardingResult(first); + + const second = sanitizeSystemRunParamsForForwarding({ + rawParams: params, + client, + execApprovalManager: approvalManager, + nowMs: now, + }); + expect(second.ok).toBe(false); + if (second.ok) { + throw new Error("unreachable"); + } + expect(second.details?.code).toBe("APPROVAL_REQUIRED"); + }); test("rejects approval ids that do not bind a nodeId", () => { const record = makeRecord("echo SAFE"); diff --git a/src/gateway/node-invoke-system-run-approval.ts b/src/gateway/node-invoke-system-run-approval.ts index 5bf31db8fb52..d5600adf032a 100644 --- a/src/gateway/node-invoke-system-run-approval.ts +++ b/src/gateway/node-invoke-system-run-approval.ts @@ -17,6 +17,7 @@ type SystemRunParamsLike = { type ApprovalLookup = { getSnapshot: (recordId: string) => ExecApprovalRecord | null; + consumeAllowOnce?: (recordId: string) => boolean; }; type ApprovalClient = { @@ -245,9 +246,22 @@ export function sanitizeSystemRunParamsForForwarding(opts: { } // Normal path: enforce the decision recorded by the gateway. - if (snapshot.decision === "allow-once" || snapshot.decision === "allow-always") { + if (snapshot.decision === "allow-once") { + if (typeof manager.consumeAllowOnce !== "function" || !manager.consumeAllowOnce(runId)) { + return { + ok: false, + message: "approval required", + details: { code: "APPROVAL_REQUIRED", runId }, + }; + } + next.approved = true; + next.approvalDecision = "allow-once"; + return { ok: true, params: next }; + } + + if (snapshot.decision === "allow-always") { next.approved = true; - next.approvalDecision = snapshot.decision; + next.approvalDecision = "allow-always"; return { ok: true, params: next }; } From c6bb7b0c04f29fb2d0d4687dcef9d4943ac87f51 Mon Sep 17 00:00:00 2001 From: damaozi <1811866786@qq.com> Date: Tue, 24 Feb 2026 03:20:37 +0800 Subject: [PATCH 093/408] fix(whatsapp): groupAllowFrom sender filter bypassed when groupPolicy is allowlist (#24670) (cherry picked from commit af06ebd9a63c5fb91d7481a61fcdd60dac955b59) --- CHANGELOG.md | 1 + src/config/group-policy.test.ts | 40 +++++++++++++++++++ src/config/group-policy.ts | 10 ++++- .../auto-reply/monitor/group-activation.ts | 7 ++++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07b72149af71..d882c0c83cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai - Security/Config: redact sensitive-looking dynamic catchall keys in `config.get` snapshots (for example `env.*` and `skills.entries.*.env.*`) and preserve round-trip restore behavior for those redacted sentinels. Thanks @merc1305. - Tests/Vitest: tier local parallel worker defaults by host memory, keep gateway serial by default on non-high-memory hosts, and document a low-profile fallback command for memory-constrained land/gate runs to prevent local OOMs. (#24719) Thanks @ngutman. +- WhatsApp/Group policy: fix `groupAllowFrom` sender filtering when `groupPolicy: "allowlist"` is set without explicit `groups` — previously all group messages were blocked even for allowlisted senders. (#24670) - Agents/Context pruning: extend `cache-ttl` eligibility to Moonshot/Kimi and ZAI/GLM providers (including OpenRouter model refs), so `contextPruning.mode: "cache-ttl"` is no longer silently skipped for those sessions. (#24497) Thanks @lailoo. - Tools/web_search: add `provider: "kimi"` (Moonshot) support with key/config schema wiring and a corrected two-step `$web_search` tool flow that echoes tool results before final synthesis, including citation extraction from search results. (#16616, #18822) Thanks @adshine. - Media understanding/Video: add a native Moonshot video provider and include Moonshot in auto video key detection, plus refactor video execution to honor `entry/config/provider` baseUrl+header precedence (matching audio behavior). (#12063) Thanks @xiaoyaner0201. diff --git a/src/config/group-policy.test.ts b/src/config/group-policy.test.ts index 8151f36363b5..a3ca8ad5327e 100644 --- a/src/config/group-policy.test.ts +++ b/src/config/group-policy.test.ts @@ -89,6 +89,46 @@ describe("resolveChannelGroupPolicy", () => { expect(policy.allowlistEnabled).toBe(true); expect(policy.allowed).toBe(false); }); + + it("allows groups when groupPolicy=allowlist with hasGroupAllowFrom but no groups", () => { + const cfg = { + channels: { + whatsapp: { + groupPolicy: "allowlist", + }, + }, + } as OpenClawConfig; + + const policy = resolveChannelGroupPolicy({ + cfg, + channel: "whatsapp", + groupId: "123@g.us", + hasGroupAllowFrom: true, + }); + + expect(policy.allowlistEnabled).toBe(true); + expect(policy.allowed).toBe(true); + }); + + it("still fails closed when groupPolicy=allowlist without groups or groupAllowFrom", () => { + const cfg = { + channels: { + whatsapp: { + groupPolicy: "allowlist", + }, + }, + } as OpenClawConfig; + + const policy = resolveChannelGroupPolicy({ + cfg, + channel: "whatsapp", + groupId: "123@g.us", + hasGroupAllowFrom: false, + }); + + expect(policy.allowlistEnabled).toBe(true); + expect(policy.allowed).toBe(false); + }); }); describe("resolveToolsBySender", () => { diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts index fe8b1542a129..fdb028f9f7c9 100644 --- a/src/config/group-policy.ts +++ b/src/config/group-policy.ts @@ -328,6 +328,8 @@ export function resolveChannelGroupPolicy(params: { groupId?: string | null; accountId?: string | null; groupIdCaseInsensitive?: boolean; + /** When true, sender-level filtering (groupAllowFrom) is configured upstream. */ + hasGroupAllowFrom?: boolean; }): ChannelGroupPolicy { const { cfg, channel } = params; const groups = resolveChannelGroups(cfg, channel, params.accountId); @@ -340,8 +342,14 @@ export function resolveChannelGroupPolicy(params: { : undefined; const defaultConfig = groups?.["*"]; const allowAll = allowlistEnabled && Boolean(groups && Object.hasOwn(groups, "*")); + // When groupPolicy is "allowlist" with groupAllowFrom but no explicit groups, + // allow the group through — sender-level filtering handles access control. + const senderFilterBypass = + groupPolicy === "allowlist" && !hasGroups && Boolean(params.hasGroupAllowFrom); const allowed = - groupPolicy === "disabled" ? false : !allowlistEnabled || allowAll || Boolean(groupConfig); + groupPolicy === "disabled" + ? false + : !allowlistEnabled || allowAll || Boolean(groupConfig) || senderFilterBypass; return { allowlistEnabled, allowed, diff --git a/src/web/auto-reply/monitor/group-activation.ts b/src/web/auto-reply/monitor/group-activation.ts index aeb16428fbe1..01f96e945287 100644 --- a/src/web/auto-reply/monitor/group-activation.ts +++ b/src/web/auto-reply/monitor/group-activation.ts @@ -16,10 +16,17 @@ export function resolveGroupPolicyFor(cfg: ReturnType, conver ChatType: "group", Provider: "whatsapp", })?.id; + const whatsappCfg = cfg.channels?.whatsapp as + | { groupAllowFrom?: string[]; allowFrom?: string[] } + | undefined; + const hasGroupAllowFrom = Boolean( + whatsappCfg?.groupAllowFrom?.length || whatsappCfg?.allowFrom?.length, + ); return resolveChannelGroupPolicy({ cfg, channel: "whatsapp", groupId: groupId ?? conversationId, + hasGroupAllowFrom, }); } From f3459d71e82838274fbb2aeceac1822e1b3b46bc Mon Sep 17 00:00:00 2001 From: SidQin-cyber Date: Tue, 24 Feb 2026 11:58:52 +0800 Subject: [PATCH 094/408] fix(exec): treat shell exit codes 126/127 as failures instead of completed When a command exits with code 127 (command not found) or 126 (not executable), the exec tool previously returned status "completed" with the error buried in the output text. This caused cron jobs to report status "ok" and never increment consecutiveErrors, silently swallowing failures like `python: command not found` across multiple daily cycles. Now these shell-reserved exit codes are classified as "failed", which propagates through the cron pipeline to properly increment consecutiveErrors and surface the issue for operator attention. Fixes #24587 Co-authored-by: Cursor (cherry picked from commit 2b1d1985ef09000977131bbb1a5c2d732b6cd6e4) --- src/agents/bash-tools.exec-runtime.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 39e36b5581e4..2a6db05669c3 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -482,7 +482,13 @@ export async function runExecProcess(opts: { .then((exit): ExecProcessOutcome => { const durationMs = Date.now() - startedAt; const isNormalExit = exit.reason === "exit"; - const status: "completed" | "failed" = isNormalExit ? "completed" : "failed"; + const exitCode = exit.exitCode ?? 0; + // Shell exit codes 126 (not executable) and 127 (command not found) are + // unrecoverable infrastructure failures that should surface as real errors + // rather than silently completing — e.g. `python: command not found`. + const isShellFailure = exitCode === 126 || exitCode === 127; + const status: "completed" | "failed" = + isNormalExit && !isShellFailure ? "completed" : "failed"; markExited(session, exit.exitCode, exit.exitSignal, status); maybeNotifyOnExit(session, status); @@ -491,7 +497,6 @@ export async function runExecProcess(opts: { } const aggregated = session.aggregated.trim(); if (status === "completed") { - const exitCode = exit.exitCode ?? 0; const exitMsg = exitCode !== 0 ? `\n\n(Command exited with code ${exitCode})` : ""; return { status: "completed", @@ -502,8 +507,11 @@ export async function runExecProcess(opts: { timedOut: false, }; } - const reason = - exit.reason === "overall-timeout" + const reason = isShellFailure + ? exitCode === 127 + ? "Command not found" + : "Command not executable (permission denied)" + : exit.reason === "overall-timeout" ? typeof opts.timeoutSec === "number" && opts.timeoutSec > 0 ? `Command timed out after ${opts.timeoutSec} seconds` : "Command timed out" From c69fc383b909758acf787288b075a641b4712516 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Tue, 24 Feb 2026 04:24:55 +0100 Subject: [PATCH 095/408] fix(config): surface helpful chown hint on EACCES when reading config When the gateway is deployed in a Docker/container environment using a 1-click hosting template, the openclaw.json config file can end up owned by root (mode 600) while the gateway process runs as the non-root 'node' user. This causes a silent EACCES failure: the gateway starts with an empty config and Telegram/Discord bots stop responding. Before this fix the error was logged as a generic 'read failed: ...' message with no indication of how to recover. After this fix: - EACCES errors log a clear, actionable error to stderr (visible in docker logs) with the exact chown command to run - The config snapshot issue message also includes the chown hint so 'openclaw gateway status' / Control UI surface the fix path - process.getuid() is used to include the current UID in the hint; falls back to '1001' on platforms where it is unavailable Fixes #24853 (cherry picked from commit 0a3c572c4175953b0d1284993642b1689678fce4) --- src/config/io.eacces.test.ts | 60 ++++++++++++++++++++++++++++++++++++ src/config/io.ts | 21 ++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 src/config/io.eacces.test.ts diff --git a/src/config/io.eacces.test.ts b/src/config/io.eacces.test.ts new file mode 100644 index 000000000000..f22b9d8905d1 --- /dev/null +++ b/src/config/io.eacces.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { createConfigIO } from "./io.js"; + +function makeEaccesFs(configPath: string) { + const eaccesErr = Object.assign(new Error(`EACCES: permission denied, open '${configPath}'`), { + code: "EACCES", + }); + return { + existsSync: (p: string) => p === configPath, + readFileSync: (p: string): string => { + if (p === configPath) { + throw eaccesErr; + } + throw new Error(`unexpected readFileSync: ${p}`); + }, + promises: { + readFile: () => Promise.reject(eaccesErr), + mkdir: () => Promise.resolve(), + writeFile: () => Promise.resolve(), + appendFile: () => Promise.resolve(), + }, + } as unknown as typeof import("node:fs").default; +} + +describe("config io EACCES handling", () => { + it("returns a helpful error message when config file is not readable (EACCES)", async () => { + const configPath = "/data/.openclaw/openclaw.json"; + const errors: string[] = []; + const io = createConfigIO({ + configPath, + fs: makeEaccesFs(configPath), + logger: { + error: (msg: unknown) => errors.push(String(msg)), + warn: () => {}, + }, + }); + + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.valid).toBe(false); + expect(snapshot.issues).toHaveLength(1); + expect(snapshot.issues[0].message).toContain("EACCES"); + expect(snapshot.issues[0].message).toContain("chown"); + expect(snapshot.issues[0].message).toContain(configPath); + // Should also emit to the logger + expect(errors.some((e) => e.includes("chown"))).toBe(true); + }); + + it("includes configPath in the chown hint for the correct remediation command", async () => { + const configPath = "/home/myuser/.openclaw/openclaw.json"; + const io = createConfigIO({ + configPath, + fs: makeEaccesFs(configPath), + logger: { error: () => {}, warn: () => {} }, + }); + + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.issues[0].message).toContain(configPath); + expect(snapshot.issues[0].message).toContain("container"); + }); +}); diff --git a/src/config/io.ts b/src/config/io.ts index bff292048fbd..8dbcf10936c2 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -936,6 +936,25 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { envSnapshotForRestore: readResolution.envSnapshotForRestore, }; } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + let message: string; + if (nodeErr?.code === "EACCES") { + // Permission denied — common in Docker/container deployments where the + // config file is owned by root but the gateway runs as a non-root user. + const uid = process.getuid?.(); + const uidHint = typeof uid === "number" ? String(uid) : "$(id -u)"; + message = [ + `read failed: ${String(err)}`, + ``, + `Config file is not readable by the current process. If running in a container`, + `or 1-click deployment, fix ownership with:`, + ` chown ${uidHint} "${configPath}"`, + `Then restart the gateway.`, + ].join("\n"); + deps.logger.error(message); + } else { + message = `read failed: ${String(err)}`; + } return { snapshot: { path: configPath, @@ -946,7 +965,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { valid: false, config: {}, hash: hashConfigRaw(null), - issues: [{ path: "", message: `read failed: ${String(err)}` }], + issues: [{ path: "", message }], warnings: [], legacyIssues: [], }, From 2398b5137803a5b68e6d0d03723ad3b827f99180 Mon Sep 17 00:00:00 2001 From: User Date: Tue, 24 Feb 2026 09:59:44 +0800 Subject: [PATCH 096/408] fix: include available_skills in isolated cron agentTurn sessions (closes #24888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildSkillsSection() had an early-return guard on isMinimal that silently dropped the entire block for any session using promptMode="minimal" — which includes all isolated cron agentTurn sessions (isCronSessionKey → promptMode="minimal" in attempt.ts:497-500). Fix: remove the isMinimal guard from buildSkillsSection so that skills are emitted whenever a non-empty skillsPrompt is provided, regardless of mode. Memory, docs, reply-tags, and other verbose sections remain gated on isMinimal. Tests added: - "includes skills in minimal prompt mode when skillsPrompt is provided (cron regression)" - "omits skills in minimal prompt mode when skillsPrompt is absent" - Updated existing minimal-mode test expectation to match corrected behaviour. (cherry picked from commit 66af86e7eede75721a0439cff595209aa4548eff) --- src/agents/system-prompt.test.ts | 26 +++++++++++++++++++++++++- src/agents/system-prompt.ts | 3 --- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index fa6d4de65633..b45c64e72eca 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -108,7 +108,8 @@ describe("buildAgentSystemPrompt", () => { }); expect(prompt).not.toContain("## Authorized Senders"); - expect(prompt).not.toContain("## Skills"); + // Skills are included even in minimal mode when skillsPrompt is provided (cron sessions need them) + expect(prompt).toContain("## Skills"); expect(prompt).not.toContain("## Memory Recall"); expect(prompt).not.toContain("## Documentation"); expect(prompt).not.toContain("## Reply Tags"); @@ -131,6 +132,29 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("Subagent details"); }); + it("includes skills in minimal prompt mode when skillsPrompt is provided (cron regression)", () => { + // Isolated cron sessions use promptMode="minimal" but must still receive skills. + const skillsPrompt = + "\n \n demo\n \n"; + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + promptMode: "minimal", + skillsPrompt, + }); + + expect(prompt).toContain("## Skills (mandatory)"); + expect(prompt).toContain(""); + }); + + it("omits skills in minimal prompt mode when skillsPrompt is absent", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + promptMode: "minimal", + }); + + expect(prompt).not.toContain("## Skills"); + }); + it("includes safety guardrails in full prompts", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 9027bba92d7a..b0b3ed8be0cd 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -22,9 +22,6 @@ function buildSkillsSection(params: { isMinimal: boolean; readToolName: string; }) { - if (params.isMinimal) { - return []; - } const trimmed = params.skillsPrompt?.trim(); if (!trimmed) { return []; From c7bf0dacb809a8f2ddf344d8e66753f7c58b00ba Mon Sep 17 00:00:00 2001 From: User Date: Tue, 24 Feb 2026 10:39:41 +0800 Subject: [PATCH 097/408] chore: remove unused isMinimal param from buildSkillsSection Address review feedback: isMinimal is no longer referenced after the early-return guard was removed in the parent commit. (cherry picked from commit 2efe04d301c386f1c7dc93d4ae60de8fac8a63b2) --- src/agents/system-prompt.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index b0b3ed8be0cd..d052daf5f7d9 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -17,11 +17,7 @@ import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; export type PromptMode = "full" | "minimal" | "none"; type OwnerIdDisplay = "raw" | "hash"; -function buildSkillsSection(params: { - skillsPrompt?: string; - isMinimal: boolean; - readToolName: string; -}) { +function buildSkillsSection(params: { skillsPrompt?: string; readToolName: string }) { const trimmed = params.skillsPrompt?.trim(); if (!trimmed) { return []; @@ -392,7 +388,6 @@ export function buildAgentSystemPrompt(params: { ]; const skillsSection = buildSkillsSection({ skillsPrompt, - isMinimal, readToolName, }); const memorySection = buildMemorySection({ From 01c1f68ab3c333b45c806687ec7d40675bcececb Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Mon, 23 Feb 2026 23:17:36 -0300 Subject: [PATCH 098/408] fix(hooks): decouple message:sent internal hook from mirror param (cherry picked from commit 1afd7030f8e5e9dda682f1de5942a9662ac7dbcf) --- src/infra/outbound/deliver.test.ts | 31 +++++++++++++++++++++++++++++- src/infra/outbound/deliver.ts | 4 +++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 0927de7df991..c39d966b804e 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -478,7 +478,7 @@ describe("deliverOutboundPayloads", () => { expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1); }); - it("does not emit internal message:sent hook when mirror sessionKey is missing", async () => { + it("does not emit internal message:sent hook when neither mirror nor sessionKey is provided", async () => { const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" }); await deliverOutboundPayloads({ @@ -493,6 +493,35 @@ describe("deliverOutboundPayloads", () => { expect(internalHookMocks.triggerInternalHook).not.toHaveBeenCalled(); }); + it("emits internal message:sent hook when sessionKey is provided without mirror", async () => { + const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" }); + + await deliverOutboundPayloads({ + cfg: whatsappChunkConfig, + channel: "whatsapp", + to: "+1555", + payloads: [{ text: "hello" }], + deps: { sendWhatsApp }, + sessionKey: "agent:main:main", + }); + + expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledTimes(1); + expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith( + "message", + "sent", + "agent:main:main", + expect.objectContaining({ + to: "+1555", + content: "hello", + success: true, + channelId: "whatsapp", + conversationId: "+1555", + messageId: "w1", + }), + ); + expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1); + }); + it("calls failDelivery instead of ackDelivery on bestEffort partial failure", async () => { const sendWhatsApp = vi .fn() diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 908b786e5ee0..f071a25d048f 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -216,6 +216,8 @@ type DeliverOutboundPayloadsCoreParams = { mediaUrls?: string[]; }; silent?: boolean; + /** Session key for internal hook dispatch (when `mirror` is not needed). */ + sessionKey?: string; }; type DeliverOutboundPayloadsParams = DeliverOutboundPayloadsCoreParams & { @@ -444,7 +446,7 @@ async function deliverOutboundPayloadsCore( return normalized ? [normalized] : []; }); const hookRunner = getGlobalHookRunner(); - const sessionKeyForInternalHooks = params.mirror?.sessionKey; + const sessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.sessionKey; for (const payload of normalizedPayloads) { const payloadSummary: NormalizedOutboundPayload = { text: payload.text ?? "", From ac6cec7677a6ea1185891107d9a32c51a4269109 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Mon, 23 Feb 2026 21:42:36 +0100 Subject: [PATCH 099/408] fix(providers): strip trailing /v1 from Anthropic baseUrl to prevent double-path The pi-ai Anthropic provider constructs the full API endpoint as `${baseUrl}/v1/messages`. If a user configures `models.providers.anthropic.baseUrl` with a trailing `/v1` (e.g. "https://api.anthropic.com/v1"), the resolved URL becomes "https://api.anthropic.com/v1/v1/messages" which the Anthropic API rejects with a 404 / connection failure. This regression appeared in v2026.2.22 when @mariozechner/pi-ai bumped from 0.54.0 to 0.54.1, which started appending the /v1 segment where the previous version did not. Fix: in normalizeModelCompat(), detect anthropic-messages models and strip a single trailing /v1 (with optional trailing slash) from the configured baseUrl before it is handed to pi-ai. Models with baseUrls that do not end in /v1 are unaffected. Non-anthropic-messages models are not touched. Adds 6 unit tests covering the normalisation scenarios. Fixes #24709 (cherry picked from commit 4c4857fdcb3506dc277f9df75d4df5879dca8d41) --- src/agents/model-compat.test.ts | 59 +++++++++++++++++++++++++++++++++ src/agents/model-compat.ts | 26 +++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index a7404d3042b5..1e11b12437f9 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -41,6 +41,65 @@ function createRegistry(models: Record>): ModelRegistry { } as ModelRegistry; } +describe("normalizeModelCompat — Anthropic baseUrl", () => { + const anthropicBase = (): Model => + ({ + id: "claude-opus-4-6", + name: "claude-opus-4-6", + api: "anthropic-messages", + provider: "anthropic", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + }) as Model; + + it("strips /v1 suffix from anthropic-messages baseUrl", () => { + const model = { ...anthropicBase(), baseUrl: "https://api.anthropic.com/v1" }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://api.anthropic.com"); + }); + + it("strips trailing /v1/ (with slash) from anthropic-messages baseUrl", () => { + const model = { ...anthropicBase(), baseUrl: "https://api.anthropic.com/v1/" }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://api.anthropic.com"); + }); + + it("leaves anthropic-messages baseUrl without /v1 unchanged", () => { + const model = { ...anthropicBase(), baseUrl: "https://api.anthropic.com" }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://api.anthropic.com"); + }); + + it("leaves baseUrl undefined unchanged for anthropic-messages", () => { + const model = anthropicBase(); + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBeUndefined(); + }); + + it("does not strip /v1 from non-anthropic-messages models", () => { + const model = { + ...baseModel(), + provider: "openai", + api: "openai-responses" as Api, + baseUrl: "https://api.openai.com/v1", + }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://api.openai.com/v1"); + }); + + it("strips /v1 from custom Anthropic proxy baseUrl", () => { + const model = { + ...anthropicBase(), + baseUrl: "https://my-proxy.example.com/anthropic/v1", + }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://my-proxy.example.com/anthropic"); + }); +}); + describe("normalizeModelCompat", () => { it("forces supportsDeveloperRole off for z.ai models", () => { const model = baseModel(); diff --git a/src/agents/model-compat.ts b/src/agents/model-compat.ts index 2b5eba1301c1..fc1c195819a5 100644 --- a/src/agents/model-compat.ts +++ b/src/agents/model-compat.ts @@ -12,8 +12,34 @@ function isDashScopeCompatibleEndpoint(baseUrl: string): boolean { ); } +function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages"> { + return model.api === "anthropic-messages"; +} + +/** + * pi-ai constructs the Anthropic API endpoint as `${baseUrl}/v1/messages`. + * If a user configures `baseUrl` with a trailing `/v1` (e.g. the previously + * recommended format "https://api.anthropic.com/v1"), the resulting URL + * becomes "…/v1/v1/messages" which the Anthropic API rejects with a 404. + * + * Strip a single trailing `/v1` (with optional trailing slash) from the + * baseUrl for anthropic-messages models so users with either format work. + */ +function normalizeAnthropicBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/v1\/?$/, ""); +} export function normalizeModelCompat(model: Model): Model { const baseUrl = model.baseUrl ?? ""; + + // Normalise anthropic-messages baseUrl: strip trailing /v1 that users may + // have included in their config. pi-ai appends /v1/messages itself. + if (isAnthropicMessagesModel(model) && baseUrl) { + const normalised = normalizeAnthropicBaseUrl(baseUrl); + if (normalised !== baseUrl) { + return { ...model, baseUrl: normalised } as Model<"anthropic-messages">; + } + } + const isZai = model.provider === "zai" || baseUrl.includes("api.z.ai"); const isMoonshot = model.provider === "moonshot" || From dd14daab150ba9bb327003ac76d01a5515e432ed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:18:43 +0000 Subject: [PATCH 100/408] fix(telegram): allowlist api.telegram.org in media SSRF policy --- src/telegram/bot/delivery.resolve-media-retry.test.ts | 5 ++++- src/telegram/bot/delivery.ts | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/telegram/bot/delivery.resolve-media-retry.test.ts b/src/telegram/bot/delivery.resolve-media-retry.test.ts index 2becbcd93e9e..d6f4e8fadc09 100644 --- a/src/telegram/bot/delivery.resolve-media-retry.test.ts +++ b/src/telegram/bot/delivery.resolve-media-retry.test.ts @@ -95,7 +95,10 @@ async function expectTransientGetFileRetrySuccess() { expect(fetchRemoteMedia).toHaveBeenCalledWith( expect.objectContaining({ url: `https://api.telegram.org/file/bot${BOT_TOKEN}/voice/file_0.oga`, - ssrfPolicy: { allowRfc2544BenchmarkRange: true }, + ssrfPolicy: { + allowRfc2544BenchmarkRange: true, + allowedHostnames: ["api.telegram.org"], + }, }), ); return result; diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index a20bf0456102..019f42ced1d5 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -36,6 +36,9 @@ const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; const FILE_TOO_BIG_RE = /file is too big/i; const TELEGRAM_MEDIA_SSRF_POLICY = { + // Telegram file downloads should trust api.telegram.org even when DNS/proxy + // resolution maps to private/internal ranges in restricted networks. + allowedHostnames: ["api.telegram.org"], allowRfc2544BenchmarkRange: true, } as const; From 803e02d8dfd3985d2df9d94608f7148714c3d0ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:20:24 +0000 Subject: [PATCH 101/408] fix: adapt landed fixups to current type and approval constraints --- src/config/io.eacces.test.ts | 2 +- src/gateway/node-invoke-system-run-approval.test.ts | 3 +++ src/telegram/bot/delivery.ts | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/config/io.eacces.test.ts b/src/config/io.eacces.test.ts index f22b9d8905d1..ab56e27a659f 100644 --- a/src/config/io.eacces.test.ts +++ b/src/config/io.eacces.test.ts @@ -19,7 +19,7 @@ function makeEaccesFs(configPath: string) { writeFile: () => Promise.resolve(), appendFile: () => Promise.resolve(), }, - } as unknown as typeof import("node:fs").default; + } as unknown as typeof import("node:fs"); } describe("config io EACCES handling", () => { diff --git a/src/gateway/node-invoke-system-run-approval.test.ts b/src/gateway/node-invoke-system-run-approval.test.ts index 653f0d478522..196b5947f451 100644 --- a/src/gateway/node-invoke-system-run-approval.test.ts +++ b/src/gateway/node-invoke-system-run-approval.test.ts @@ -145,6 +145,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { const record = approvalManager.create( { host: "node", + nodeId: "node-1", command: "echo SAFE", cwd: null, agentId: null, @@ -170,6 +171,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { }; const first = sanitizeSystemRunParamsForForwarding({ + nodeId: "node-1", rawParams: params, client, execApprovalManager: approvalManager, @@ -178,6 +180,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { expectAllowOnceForwardingResult(first); const second = sanitizeSystemRunParamsForForwarding({ + nodeId: "node-1", rawParams: params, client, execApprovalManager: approvalManager, diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 019f42ced1d5..5e0cfb2ea1f3 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -40,7 +40,7 @@ const TELEGRAM_MEDIA_SSRF_POLICY = { // resolution maps to private/internal ranges in restricted networks. allowedHostnames: ["api.telegram.org"], allowRfc2544BenchmarkRange: true, -} as const; +}; export async function deliverReplies(params: { replies: ReplyPayload[]; From 5710d72527287df593894da2365b53dcaf924fdc Mon Sep 17 00:00:00 2001 From: Mitch McAlister Date: Mon, 23 Feb 2026 17:25:08 +0000 Subject: [PATCH 102/408] feat(agents): configurable default runTimeoutSeconds for subagent spawns When sessions_spawn is called without runTimeoutSeconds, subagents previously defaulted to 0 (no timeout). This adds a config key at agents.defaults.subagents.runTimeoutSeconds so operators can set a global default timeout for all subagent runs. The agent-provided value still takes precedence when explicitly passed. When neither the agent nor the config specifies a timeout, behavior is unchanged (0 = no timeout), preserving backwards compatibility. Updated for the subagent-spawn.ts refactor (logic moved from sessions-spawn-tool.ts to spawnSubagentDirect). Closes #19288 Co-Authored-By: Claude Opus 4.6 --- ...sions-spawn-default-timeout-absent.test.ts | 69 ++++++++++++++++ ...nts.sessions-spawn-default-timeout.test.ts | 79 +++++++++++++++++++ src/agents/subagent-spawn.ts | 14 +++- src/config/types.agent-defaults.ts | 2 + src/config/zod-schema.agent-defaults.ts | 1 + 5 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts create mode 100644 src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts new file mode 100644 index 000000000000..947c83333fd8 --- /dev/null +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; + +vi.mock("../config/config.js", async () => { + const actual = await vi.importActual("../config/config.js"); + return { + ...actual, + loadConfig: () => ({ + agents: { + defaults: { + subagents: { + maxConcurrent: 8, + }, + }, + }, + routing: { + sessions: { + mainKey: "agent:test:main", + }, + }, + }), + }; +}); + +vi.mock("../gateway/call.js", () => { + return { + callGateway: vi.fn(async ({ method }: { method: string }) => { + if (method === "agent") { + return { runId: "run-456" }; + } + return {}; + }), + }; +}); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => null, +})); + +type GatewayCall = { method: string; params?: Record }; + +async function getGatewayCalls(): Promise { + const { callGateway } = await import("../gateway/call.js"); + return (callGateway as unknown as ReturnType).mock.calls.map( + (call) => call[0] as GatewayCall, + ); +} + +function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) { + for (let i = calls.length - 1; i >= 0; i -= 1) { + const call = calls[i]; + if (call && predicate(call)) { + return call; + } + } + return undefined; +} + +describe("sessions_spawn default runTimeoutSeconds (config absent)", () => { + it("falls back to 0 (no timeout) when config key is absent", async () => { + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); + const result = await tool.execute("call-1", { task: "hello" }); + expect(result.details).toMatchObject({ status: "accepted" }); + + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + expect(agentCall?.params?.timeout).toBe(0); + }); +}); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts new file mode 100644 index 000000000000..8186b8bde95f --- /dev/null +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; + +vi.mock("../config/config.js", async () => { + const actual = await vi.importActual("../config/config.js"); + return { + ...actual, + loadConfig: () => ({ + agents: { + defaults: { + subagents: { + runTimeoutSeconds: 900, + }, + }, + }, + routing: { + sessions: { + mainKey: "agent:test:main", + }, + }, + }), + }; +}); + +vi.mock("../gateway/call.js", () => { + return { + callGateway: vi.fn(async ({ method }: { method: string }) => { + if (method === "agent") { + return { runId: "run-123" }; + } + return {}; + }), + }; +}); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => null, +})); + +type GatewayCall = { method: string; params?: Record }; + +async function getGatewayCalls(): Promise { + const { callGateway } = await import("../gateway/call.js"); + return (callGateway as unknown as ReturnType).mock.calls.map( + (call) => call[0] as GatewayCall, + ); +} + +function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) { + for (let i = calls.length - 1; i >= 0; i -= 1) { + const call = calls[i]; + if (call && predicate(call)) { + return call; + } + } + return undefined; +} + +describe("sessions_spawn default runTimeoutSeconds", () => { + it("uses config default when agent omits runTimeoutSeconds", async () => { + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); + const result = await tool.execute("call-1", { task: "hello" }); + expect(result.details).toMatchObject({ status: "accepted" }); + + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + expect(agentCall?.params?.timeout).toBe(900); + }); + + it("explicit runTimeoutSeconds wins over config default", async () => { + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); + const result = await tool.execute("call-2", { task: "hello", runTimeoutSeconds: 300 }); + expect(result.details).toMatchObject({ status: "accepted" }); + + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + expect(agentCall?.params?.timeout).toBe(300); + }); +}); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index d033c78bc3e7..7d4f672f2f1e 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -193,14 +193,22 @@ export async function spawnSubagentDirect( threadId: ctx.agentThreadId, }); const hookRunner = getGlobalHookRunner(); + const cfg = loadConfig(); + + // When agent omits runTimeoutSeconds, use the config default. + // Falls back to 0 (no timeout) if config key is also unset, + // preserving current behavior for existing deployments. + const cfgSubagentTimeout = + typeof cfg?.agents?.defaults?.subagents?.runTimeoutSeconds === "number" && + Number.isFinite(cfg.agents.defaults.subagents.runTimeoutSeconds) + ? Math.max(0, Math.floor(cfg.agents.defaults.subagents.runTimeoutSeconds)) + : 0; const runTimeoutSeconds = typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds) ? Math.max(0, Math.floor(params.runTimeoutSeconds)) - : 0; + : cfgSubagentTimeout; let modelApplied = false; let threadBindingReady = false; - - const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); const requesterSessionKey = ctx.agentSessionKey; const requesterInternalKey = requesterSessionKey diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 7ecfc6d4193d..e8eac6850865 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -247,6 +247,8 @@ export type AgentDefaultsConfig = { model?: AgentModelConfig; /** Default thinking level for spawned sub-agents (e.g. "off", "low", "medium", "high"). */ thinking?: string; + /** Default run timeout in seconds for spawned sub-agents (0 = no timeout). */ + runTimeoutSeconds?: number; /** Gateway timeout in ms for sub-agent announce delivery calls (default: 60000). */ announceTimeoutMs?: number; }; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index a4fb3c2443bc..6f80698f079c 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -146,6 +146,7 @@ export const AgentDefaultsSchema = z archiveAfterMinutes: z.number().int().positive().optional(), model: AgentModelSchema.optional(), thinking: z.string().optional(), + runTimeoutSeconds: z.number().min(0).optional(), announceTimeoutMs: z.number().int().positive().optional(), }) .strict() From 8bcd405b1cb72f2aec671762fcdc5ef1c290d57e Mon Sep 17 00:00:00 2001 From: Mitch McAlister Date: Mon, 23 Feb 2026 17:33:58 +0000 Subject: [PATCH 103/408] fix: add .int() to runTimeoutSeconds zod schema for consistency Matches convention used by all other *Seconds/*Ms timeout fields. Co-Authored-By: Claude Opus 4.6 --- src/config/zod-schema.agent-defaults.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 6f80698f079c..aa39a70978bb 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -146,7 +146,7 @@ export const AgentDefaultsSchema = z archiveAfterMinutes: z.number().int().positive().optional(), model: AgentModelSchema.optional(), thinking: z.string().optional(), - runTimeoutSeconds: z.number().min(0).optional(), + runTimeoutSeconds: z.number().int().min(0).optional(), announceTimeoutMs: z.number().int().positive().optional(), }) .strict() From 8c5cf2d5b275203cb25f1db9f3d8c259725c3ed3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:22:25 +0000 Subject: [PATCH 104/408] docs(subagents): document default runTimeoutSeconds config (#24594) (thanks @mitchmcalister) --- CHANGELOG.md | 4 ++++ docs/concepts/session-tool.md | 2 +- docs/gateway/configuration-reference.md | 2 ++ docs/tools/index.md | 1 + docs/tools/subagents.md | 4 +++- 5 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d882c0c83cb9..d1efde75b9f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Docs: https://docs.openclaw.ai - **BREAKING:** non-loopback Control UI now requires explicit `gateway.controlUi.allowedOrigins` (full origins). Startup fails closed when missing unless `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` is set to use Host-header origin fallback mode. +### Changes + +- Subagents/Sessions: add `agents.defaults.subagents.runTimeoutSeconds` so `sessions_spawn` can inherit a configurable default timeout when the tool call omits `runTimeoutSeconds` (unset remains `0`, meaning no timeout). (#24594) Thanks @mitchmcalister. + ### Fixes - Gateway/Browser control: load `src/browser/server.js` during browser-control startup so the control listener starts reliably when browser control is enabled. (#23974) Thanks @ieaves. diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index ebac95dbe557..bbd58d599ced 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -152,7 +152,7 @@ Parameters: - `agentId?` (optional; spawn under another agent id if allowed) - `model?` (optional; overrides the sub-agent model; invalid values error) - `thinking?` (optional; overrides thinking level for the sub-agent run) -- `runTimeoutSeconds?` (default 0; when set, aborts the sub-agent run after N seconds) +- `runTimeoutSeconds?` (defaults to `agents.defaults.subagents.runTimeoutSeconds` when set, otherwise `0`; when set, aborts the sub-agent run after N seconds) - `thread?` (default false; request thread-bound routing for this spawn when supported by the channel/plugin) - `mode?` (`run|session`; defaults to `run`, but defaults to `session` when `thread=true`; `mode="session"` requires `thread=true`) - `cleanup?` (`delete|keep`, default `keep`) diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 8ff410363548..0b89a272d903 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1683,6 +1683,7 @@ Notes: subagents: { model: "minimax/MiniMax-M2.1", maxConcurrent: 1, + runTimeoutSeconds: 900, archiveAfterMinutes: 60, }, }, @@ -1691,6 +1692,7 @@ Notes: ``` - `model`: default model for spawned sub-agents. If omitted, sub-agents inherit the caller's model. +- `runTimeoutSeconds`: default timeout (seconds) for `sessions_spawn` when the tool call omits `runTimeoutSeconds`. `0` means no timeout. - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny`. --- diff --git a/docs/tools/index.md b/docs/tools/index.md index 88b2ee6bccdb..269b6856d038 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -478,6 +478,7 @@ Notes: - Supports one-shot mode (`mode: "run"`) and persistent thread-bound mode (`mode: "session"` with `thread: true`). - If `thread: true` and `mode` is omitted, mode defaults to `session`. - `mode: "session"` requires `thread: true`. + - If `runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise timeout defaults to `0` (no timeout). - Discord thread-bound flows depend on `session.threadBindings.*` and `channels.discord.threadBindings.*`. - Reply format includes `Status`, `Result`, and compact stats. - `Result` is the assistant completion text; if missing, the latest `toolResult` is used as fallback. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 7334da1ec401..9542858c8402 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -71,6 +71,7 @@ Use `sessions_spawn`: - Then runs an announce step and posts the announce reply to the requester chat channel - Default model: inherits the caller unless you set `agents.defaults.subagents.model` (or per-agent `agents.list[].subagents.model`); an explicit `sessions_spawn.model` still wins. - Default thinking: inherits the caller unless you set `agents.defaults.subagents.thinking` (or per-agent `agents.list[].subagents.thinking`); an explicit `sessions_spawn.thinking` still wins. +- Default run timeout: if `sessions_spawn.runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise it falls back to `0` (no timeout). Tool params: @@ -79,7 +80,7 @@ Tool params: - `agentId?` (optional; spawn under another agent id if allowed) - `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result) - `thinking?` (optional; overrides thinking level for the sub-agent run) -- `runTimeoutSeconds?` (default `0`; when set, the sub-agent run is aborted after N seconds) +- `runTimeoutSeconds?` (defaults to `agents.defaults.subagents.runTimeoutSeconds` when set, otherwise `0`; when set, the sub-agent run is aborted after N seconds) - `thread?` (default `false`; when `true`, requests channel thread binding for this sub-agent session) - `mode?` (`run|session`) - default is `run` @@ -148,6 +149,7 @@ By default, sub-agents cannot spawn their own sub-agents (`maxSpawnDepth: 1`). Y maxSpawnDepth: 2, // allow sub-agents to spawn children (default: 1) maxChildrenPerAgent: 5, // max active children per agent session (default: 5) maxConcurrent: 8, // global concurrency lane cap (default: 8) + runTimeoutSeconds: 900, // default timeout for sessions_spawn when omitted (0 = no timeout) }, }, }, From f9de17106af64940e41642418ebce15aacc6b8b3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:23:16 +0000 Subject: [PATCH 105/408] refactor(browser): share relay token + options validation tests --- assets/chrome-extension/background.js | 2 +- assets/chrome-extension/options-validation.js | 57 +++++++++ assets/chrome-extension/options.js | 58 ++------- ...hrome-extension-options-validation.test.ts | 113 ++++++++++++++++++ 4 files changed, 179 insertions(+), 51 deletions(-) create mode 100644 assets/chrome-extension/options-validation.js create mode 100644 src/browser/chrome-extension-options-validation.test.ts diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index 94956571101e..60f50d6551e5 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -1,4 +1,4 @@ -import { buildRelayWsUrl, deriveRelayToken, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' +import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' const DEFAULT_PORT = 18792 diff --git a/assets/chrome-extension/options-validation.js b/assets/chrome-extension/options-validation.js new file mode 100644 index 000000000000..53e2cd550147 --- /dev/null +++ b/assets/chrome-extension/options-validation.js @@ -0,0 +1,57 @@ +const PORT_GUIDANCE = 'Use gateway port + 3 (for gateway 18789, relay is 18792).' + +function hasCdpVersionShape(data) { + return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data +} + +export function classifyRelayCheckResponse(res, port) { + if (!res) { + return { action: 'throw', error: 'No response from service worker' } + } + + if (res.status === 401) { + return { action: 'status', kind: 'error', message: 'Gateway token rejected. Check token and save again.' } + } + + if (res.error) { + return { action: 'throw', error: res.error } + } + + if (!res.ok) { + return { action: 'throw', error: `HTTP ${res.status}` } + } + + const contentType = String(res.contentType || '') + if (!contentType.includes('application/json')) { + return { + action: 'status', + kind: 'error', + message: `Wrong port: this is likely the gateway, not the relay. ${PORT_GUIDANCE}`, + } + } + + if (!hasCdpVersionShape(res.json)) { + return { + action: 'status', + kind: 'error', + message: `Wrong port: expected relay /json/version response. ${PORT_GUIDANCE}`, + } + } + + return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://127.0.0.1:${port}/` } +} + +export function classifyRelayCheckException(err, port) { + const message = String(err || '').toLowerCase() + if (message.includes('json') || message.includes('syntax')) { + return { + kind: 'error', + message: `Wrong port: this is not a relay endpoint. ${PORT_GUIDANCE}`, + } + } + + return { + kind: 'error', + message: `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, + } +} diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js index 96b87768dae9..aa6fcc4901fd 100644 --- a/assets/chrome-extension/options.js +++ b/assets/chrome-extension/options.js @@ -1,3 +1,6 @@ +import { deriveRelayToken } from './background-utils.js' +import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js' + const DEFAULT_PORT = 18792 function clampPort(value) { @@ -13,17 +16,6 @@ function updateRelayUrl(port) { el.textContent = `http://127.0.0.1:${port}/` } -async function deriveRelayToken(gatewayToken, port) { - const enc = new TextEncoder() - const key = await crypto.subtle.importKey( - 'raw', enc.encode(gatewayToken), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], - ) - const sig = await crypto.subtle.sign( - 'HMAC', key, enc.encode(`openclaw-extension-relay-v1:${port}`), - ) - return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('') -} - function setStatus(kind, message) { const status = document.getElementById('status') if (!status) return @@ -47,46 +39,12 @@ async function checkRelayReachable(port, token) { url, token: relayToken, }) - if (!res) throw new Error('No response from service worker') - if (res.status === 401) { - setStatus('error', 'Gateway token rejected. Check token and save again.') - return - } - if (res.error) throw new Error(res.error) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - - // Validate that this is a CDP relay /json/version payload, not gateway HTML. - const contentType = String(res.contentType || '') - const data = res.json - if (!contentType.includes('application/json')) { - setStatus( - 'error', - 'Wrong port: this is likely the gateway, not the relay. Use gateway port + 3 (for gateway 18789, relay is 18792).', - ) - return - } - if (!data || typeof data !== 'object' || !('Browser' in data) || !('Protocol-Version' in data)) { - setStatus( - 'error', - 'Wrong port: expected relay /json/version response. Use gateway port + 3 (for gateway 18789, relay is 18792).', - ) - return - } - - setStatus('ok', `Relay reachable and authenticated at http://127.0.0.1:${port}/`) + const result = classifyRelayCheckResponse(res, port) + if (result.action === 'throw') throw new Error(result.error) + setStatus(result.kind, result.message) } catch (err) { - const message = String(err || '').toLowerCase() - if (message.includes('json') || message.includes('syntax')) { - setStatus( - 'error', - 'Wrong port: this is not a relay endpoint. Use gateway port + 3 (for gateway 18789, relay is 18792).', - ) - } else { - setStatus( - 'error', - `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, - ) - } + const result = classifyRelayCheckException(err, port) + setStatus(result.kind, result.message) } } diff --git a/src/browser/chrome-extension-options-validation.test.ts b/src/browser/chrome-extension-options-validation.test.ts new file mode 100644 index 000000000000..23aa6d1ce06f --- /dev/null +++ b/src/browser/chrome-extension-options-validation.test.ts @@ -0,0 +1,113 @@ +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; + +type RelayCheckResponse = { + status?: number; + ok?: boolean; + error?: string; + contentType?: string; + json?: unknown; +}; + +type RelayCheckStatus = + | { action: "throw"; error: string } + | { action: "status"; kind: "ok" | "error"; message: string }; + +type RelayCheckExceptionStatus = { kind: "error"; message: string }; + +type OptionsValidationModule = { + classifyRelayCheckResponse: ( + res: RelayCheckResponse | null | undefined, + port: number, + ) => RelayCheckStatus; + classifyRelayCheckException: (err: unknown, port: number) => RelayCheckExceptionStatus; +}; + +const require = createRequire(import.meta.url); +const OPTIONS_VALIDATION_MODULE = "../../assets/chrome-extension/options-validation.js"; + +async function loadOptionsValidation(): Promise { + try { + return require(OPTIONS_VALIDATION_MODULE) as OptionsValidationModule; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("Unexpected token 'export'")) { + throw error; + } + return (await import(OPTIONS_VALIDATION_MODULE)) as OptionsValidationModule; + } +} + +const { classifyRelayCheckException, classifyRelayCheckResponse } = await loadOptionsValidation(); + +describe("chrome extension options validation", () => { + it("maps 401 response to token rejected error", () => { + const result = classifyRelayCheckResponse({ status: 401, ok: false }, 18792); + expect(result).toEqual({ + action: "status", + kind: "error", + message: "Gateway token rejected. Check token and save again.", + }); + }); + + it("maps non-json 200 response to wrong-port error", () => { + const result = classifyRelayCheckResponse( + { status: 200, ok: true, contentType: "text/html; charset=utf-8", json: null }, + 18792, + ); + expect(result).toEqual({ + action: "status", + kind: "error", + message: + "Wrong port: this is likely the gateway, not the relay. Use gateway port + 3 (for gateway 18789, relay is 18792).", + }); + }); + + it("maps json response without CDP keys to wrong-port error", () => { + const result = classifyRelayCheckResponse( + { status: 200, ok: true, contentType: "application/json", json: { ok: true } }, + 18792, + ); + expect(result).toEqual({ + action: "status", + kind: "error", + message: + "Wrong port: expected relay /json/version response. Use gateway port + 3 (for gateway 18789, relay is 18792).", + }); + }); + + it("maps valid relay json response to success", () => { + const result = classifyRelayCheckResponse( + { + status: 200, + ok: true, + contentType: "application/json", + json: { Browser: "Chrome/136", "Protocol-Version": "1.3" }, + }, + 19004, + ); + expect(result).toEqual({ + action: "status", + kind: "ok", + message: "Relay reachable and authenticated at http://127.0.0.1:19004/", + }); + }); + + it("maps syntax/json exceptions to wrong-endpoint error", () => { + const result = classifyRelayCheckException(new Error("SyntaxError: Unexpected token <"), 18792); + expect(result).toEqual({ + kind: "error", + message: + "Wrong port: this is not a relay endpoint. Use gateway port + 3 (for gateway 18789, relay is 18792).", + }); + }); + + it("maps generic exceptions to relay unreachable error", () => { + const result = classifyRelayCheckException(new Error("TypeError: Failed to fetch"), 18792); + expect(result).toEqual({ + kind: "error", + message: + "Relay not reachable/authenticated at http://127.0.0.1:18792/. Start OpenClaw browser relay and verify token.", + }); + }); +}); From d51a4695f0cefbed1df0795fb82c27223282caab Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Mon, 23 Feb 2026 21:18:10 -0700 Subject: [PATCH 106/408] Deny cron tool on /tools/invoke by default (cherry picked from commit 816a6b3a4df5bf8436f08e3fc8fa82411e3543ac) --- .../tools-invoke-http.cron-regression.test.ts | 123 ++++++++++++++++++ src/security/dangerous-tools.ts | 2 + 2 files changed, 125 insertions(+) create mode 100644 src/gateway/tools-invoke-http.cron-regression.test.ts diff --git a/src/gateway/tools-invoke-http.cron-regression.test.ts b/src/gateway/tools-invoke-http.cron-regression.test.ts new file mode 100644 index 000000000000..a3df263387bb --- /dev/null +++ b/src/gateway/tools-invoke-http.cron-regression.test.ts @@ -0,0 +1,123 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890"; + +let cfg: Record = {}; + +vi.mock("../config/config.js", () => ({ + loadConfig: () => cfg, +})); + +vi.mock("../config/sessions.js", () => ({ + resolveMainSessionKey: () => "agent:main:main", +})); + +vi.mock("./auth.js", () => ({ + authorizeHttpGatewayConnect: async () => ({ ok: true }), +})); + +vi.mock("../logger.js", () => ({ + logWarn: () => {}, +})); + +vi.mock("../plugins/config-state.js", () => ({ + isTestDefaultMemorySlotDisabled: () => false, +})); + +vi.mock("../plugins/tools.js", () => ({ + getPluginToolMeta: () => undefined, +})); + +vi.mock("../agents/openclaw-tools.js", () => { + const tools = [ + { + name: "cron", + parameters: { type: "object", properties: { action: { type: "string" } } }, + execute: async () => ({ ok: true, via: "cron" }), + }, + { + name: "gateway", + parameters: { type: "object", properties: { action: { type: "string" } } }, + execute: async () => ({ ok: true, via: "gateway" }), + }, + ]; + return { + createOpenClawTools: () => tools, + }; +}); + +const { handleToolsInvokeHttpRequest } = await import("./tools-invoke-http.js"); + +let port = 0; +let server: ReturnType | undefined; + +beforeAll(async () => { + server = createServer((req, res) => { + void handleToolsInvokeHttpRequest(req, res, { + auth: { mode: "token", token: TEST_GATEWAY_TOKEN, allowTailscale: false }, + }).then((handled) => { + if (handled) { + return; + } + res.statusCode = 404; + res.end("not found"); + }); + }); + await new Promise((resolve, reject) => { + server?.once("error", reject); + server?.listen(0, "127.0.0.1", () => { + const address = server?.address() as AddressInfo | null; + port = address?.port ?? 0; + resolve(); + }); + }); +}); + +afterAll(async () => { + if (!server) { + return; + } + await new Promise((resolve) => server?.close(() => resolve())); + server = undefined; +}); + +beforeEach(() => { + cfg = {}; +}); + +async function invoke(tool: string) { + return await fetch(`http://127.0.0.1:${port}/tools/invoke`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${TEST_GATEWAY_TOKEN}`, + }, + body: JSON.stringify({ tool, action: "status", args: {}, sessionKey: "main" }), + }); +} + +describe("tools invoke HTTP denylist", () => { + it("blocks cron and gateway by default", async () => { + const gatewayRes = await invoke("gateway"); + const cronRes = await invoke("cron"); + + expect(gatewayRes.status).toBe(404); + expect(cronRes.status).toBe(404); + }); + + it("allows cron only when explicitly enabled in gateway.tools.allow", async () => { + cfg = { + gateway: { + tools: { + allow: ["cron"], + }, + }, + }; + + const cronRes = await invoke("cron"); + + expect(cronRes.status).toBe(200); + }); +}); diff --git a/src/security/dangerous-tools.ts b/src/security/dangerous-tools.ts index be585913bde9..6d1274723a52 100644 --- a/src/security/dangerous-tools.ts +++ b/src/security/dangerous-tools.ts @@ -11,6 +11,8 @@ export const DEFAULT_GATEWAY_HTTP_TOOL_DENY = [ "sessions_spawn", // Cross-session injection — message injection across sessions "sessions_send", + // Persistent automation control plane — can create/update/remove scheduled runs + "cron", // Gateway control plane — prevents gateway reconfiguration via HTTP "gateway", // Interactive setup — requires terminal QR scan, hangs on HTTP From 24e52f53e487ec1ea434a8352bc0ffe45b51b5d7 Mon Sep 17 00:00:00 2001 From: HCL Date: Tue, 24 Feb 2026 12:04:06 +0800 Subject: [PATCH 107/408] fix(cli): resolve --url option collision in browser cookies set When addGatewayClientOptions registers --url on the parent browser command, Commander.js captures it before the cookies set subcommand can receive it. Switch from requiredOption to option and resolve via inheritOptionFromParent, matching the existing pattern used for --target-id. Fixes #24811 Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 96fcb963ec6ef4254898aa2afa91d85b61ce677a) --- src/cli/browser-cli-state.cookies-storage.ts | 21 ++++++++++++-- ...rowser-cli-state.option-collisions.test.ts | 29 ++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/cli/browser-cli-state.cookies-storage.ts b/src/cli/browser-cli-state.cookies-storage.ts index d71cb9a04347..c3b03404f3ab 100644 --- a/src/cli/browser-cli-state.cookies-storage.ts +++ b/src/cli/browser-cli-state.cookies-storage.ts @@ -4,6 +4,17 @@ import { defaultRuntime } from "../runtime.js"; import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js"; import { inheritOptionFromParent } from "./command-options.js"; +function resolveUrl(opts: { url?: string }, command: Command): string | undefined { + if (typeof opts.url === "string" && opts.url.trim()) { + return opts.url.trim(); + } + const inherited = inheritOptionFromParent(command, "url"); + if (typeof inherited === "string" && inherited.trim()) { + return inherited.trim(); + } + return undefined; +} + function resolveTargetId(rawTargetId: unknown, command: Command): string | undefined { const local = typeof rawTargetId === "string" ? rawTargetId.trim() : ""; if (local) { @@ -58,12 +69,18 @@ export function registerBrowserCookiesAndStorageCommands( .description("Set a cookie (requires --url or domain+path)") .argument("", "Cookie name") .argument("", "Cookie value") - .requiredOption("--url ", "Cookie URL scope (recommended)") + .option("--url ", "Cookie URL scope (recommended)") .option("--target-id ", "CDP target id (or unique prefix)") .action(async (name: string, value: string, opts, cmd) => { const parent = parentOpts(cmd); const profile = parent?.browserProfile; const targetId = resolveTargetId(opts.targetId, cmd); + const url = resolveUrl(opts, cmd); + if (!url) { + defaultRuntime.error(danger("Missing required --url option for cookies set")); + defaultRuntime.exit(1); + return; + } try { const result = await callBrowserRequest( parent, @@ -73,7 +90,7 @@ export function registerBrowserCookiesAndStorageCommands( query: profile ? { profile } : undefined, body: { targetId, - cookie: { name, value, url: opts.url }, + cookie: { name, value, url }, }, }, { timeoutMs: 20000 }, diff --git a/src/cli/browser-cli-state.option-collisions.test.ts b/src/cli/browser-cli-state.option-collisions.test.ts index 7284a2de048f..45ec5c6a5c13 100644 --- a/src/cli/browser-cli-state.option-collisions.test.ts +++ b/src/cli/browser-cli-state.option-collisions.test.ts @@ -26,12 +26,15 @@ vi.mock("../runtime.js", () => ({ })); describe("browser state option collisions", () => { - const createBrowserProgram = () => { + const createBrowserProgram = ({ withGatewayUrl = false } = {}) => { const program = new Command(); const browser = program .command("browser") .option("--browser-profile ", "Browser profile") .option("--json", "Output JSON", false); + if (withGatewayUrl) { + browser.option("--url ", "Gateway WebSocket URL"); + } const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts; registerBrowserStateCommands(browser, parentOpts); return program; @@ -79,6 +82,30 @@ describe("browser state option collisions", () => { expect((request as { body?: { targetId?: string } }).body?.targetId).toBe("tab-1"); }); + it("resolves --url via parent when addGatewayClientOptions captures it", async () => { + const program = createBrowserProgram({ withGatewayUrl: true }); + await program.parseAsync( + ["browser", "--url", "ws://gw", "cookies", "set", "session", "abc", "--url", "https://example.com"], + { from: "user" }, + ); + const call = mocks.callBrowserRequest.mock.calls.at(-1); + expect(call).toBeDefined(); + const request = call![1] as { body?: { cookie?: { url?: string } } }; + expect(request.body?.cookie?.url).toBe("https://example.com"); + }); + + it("inherits --url from parent when subcommand does not provide it", async () => { + const program = createBrowserProgram({ withGatewayUrl: true }); + await program.parseAsync( + ["browser", "--url", "https://inherited.example.com", "cookies", "set", "session", "abc"], + { from: "user" }, + ); + const call = mocks.callBrowserRequest.mock.calls.at(-1); + expect(call).toBeDefined(); + const request = call![1] as { body?: { cookie?: { url?: string } } }; + expect(request.body?.cookie?.url).toBe("https://inherited.example.com"); + }); + it("accepts legacy parent `--json` by parsing payload via positional headers fallback", async () => { const request = (await runBrowserCommandAndGetRequest([ "set", From fd7ca4c3945f3cb4cdc94b27ad6a7566e9492215 Mon Sep 17 00:00:00 2001 From: Glucksberg Date: Mon, 23 Feb 2026 02:14:07 +0000 Subject: [PATCH 108/408] fix: normalize input peer.kind in resolveAgentRoute (#22730) The input peer.kind from channel plugins was used as-is without normalization via normalizeChatType(), while the binding side correctly normalized. This caused "dm" !== "direct" mismatches in matchesBindingScope, making plugins that use "dm" as peerKind fail to match bindings configured with "direct". Normalize both peer.kind and parentPeer.kind through normalizeChatType() so that "dm" and "direct" are treated equivalently on both sides. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit b0c96702f5531287f857410303b2c3cc698a1441) --- src/routing/resolve-route.test.ts | 24 ++++++++++++++++++++++++ src/routing/resolve-route.ts | 12 ++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 5337731f3e21..c92bfe2ba17f 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -521,6 +521,30 @@ describe("backward compatibility: peer.kind dm → direct", () => { expect(route.agentId).toBe("alex"); expect(route.matchedBy).toBe("binding.peer"); }); + + test("runtime dm peer.kind matches config direct binding (#22730)", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "alex", + match: { + channel: "whatsapp", + // Config uses canonical "direct" + peer: { kind: "direct", id: "+15551234567" }, + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "whatsapp", + accountId: null, + // Plugin sends "dm" instead of "direct" + peer: { kind: "dm" as ChatType, id: "+15551234567" }, + }); + expect(route.agentId).toBe("alex"); + expect(route.matchedBy).toBe("binding.peer"); + }); }); describe("role-based agent routing", () => { diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 6dab84d34209..74f1b3831b4d 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -291,7 +291,12 @@ function matchesBindingScope(match: NormalizedBindingMatch, scope: BindingScope) export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentRoute { const channel = normalizeToken(input.channel); const accountId = normalizeAccountId(input.accountId); - const peer = input.peer ? { kind: input.peer.kind, id: normalizeId(input.peer.id) } : null; + const peer = input.peer + ? { + kind: normalizeChatType(input.peer.kind) ?? input.peer.kind, + id: normalizeId(input.peer.id), + } + : null; const guildId = normalizeId(input.guildId); const teamId = normalizeId(input.teamId); const memberRoleIds = input.memberRoleIds ?? []; @@ -351,7 +356,10 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR } // Thread parent inheritance: if peer (thread) didn't match, check parent peer binding const parentPeer = input.parentPeer - ? { kind: input.parentPeer.kind, id: normalizeId(input.parentPeer.id) } + ? { + kind: normalizeChatType(input.parentPeer.kind) ?? input.parentPeer.kind, + id: normalizeId(input.parentPeer.id), + } : null; const baseScope = { guildId, From 3823587ada2efd7d63f216fa6045f39011986715 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 13:35:14 -0500 Subject: [PATCH 109/408] fix(agents): allow empty edit replacement text (cherry picked from commit 3c21fc30d38ae69f59c7200cfae76642473b2f03) --- src/agents/pi-tools.read.ts | 1 + src/agents/pi-tools.workspace-paths.test.ts | 25 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index 93abd66f2d55..a5fb9a1ccd08 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -353,6 +353,7 @@ export const CLAUDE_PARAM_GROUPS = { { keys: ["newText", "new_string"], label: "newText (newText or new_string)", + allowEmpty: true, }, ], } as const; diff --git a/src/agents/pi-tools.workspace-paths.test.ts b/src/agents/pi-tools.workspace-paths.test.ts index 625c04227d3d..969bc448caf4 100644 --- a/src/agents/pi-tools.workspace-paths.test.ts +++ b/src/agents/pi-tools.workspace-paths.test.ts @@ -60,6 +60,31 @@ describe("workspace path resolution", () => { }); }); + it("allows deletion edits with empty newText", async () => { + await withTempDir("openclaw-ws-", async (workspaceDir) => { + await withTempDir("openclaw-cwd-", async (otherDir) => { + const testFile = "delete.txt"; + await fs.writeFile(path.join(workspaceDir, testFile), "hello world", "utf8"); + + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(otherDir); + try { + const tools = createOpenClawCodingTools({ workspaceDir }); + const { editTool } = expectReadWriteEditTools(tools); + + await editTool.execute("ws-edit-delete", { + path: testFile, + oldText: " world", + newText: "", + }); + + expect(await fs.readFile(path.join(workspaceDir, testFile), "utf8")).toBe("hello"); + } finally { + cwdSpy.mockRestore(); + } + }); + }); + }); + it("defaults exec cwd to workspaceDir when workdir is omitted", async () => { await withTempDir("openclaw-ws-", async (workspaceDir) => { const tools = createOpenClawCodingTools({ From 792bd6195c2ac92a2ff846cd1b68963faf47e4a0 Mon Sep 17 00:00:00 2001 From: JackyWay Date: Tue, 24 Feb 2026 00:49:03 +0800 Subject: [PATCH 110/408] fix: recognize Bedrock as Anthropic-compatible in transcript policy (cherry picked from commit 3b5154081cdd6f9ff94b35c50b8f57714f9ad381) --- src/agents/transcript-policy.test.ts | 13 +++++++++++++ src/agents/transcript-policy.ts | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 4ef038c81b73..5f7d151ee9ad 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -53,6 +53,19 @@ describe("resolveTranscriptPolicy", () => { expect(policy.validateAnthropicTurns).toBe(true); }); + it("enables Anthropic-compatible policies for Bedrock provider", () => { + const policy = resolveTranscriptPolicy({ + provider: "amazon-bedrock", + modelId: "us.anthropic.claude-opus-4-6-v1", + modelApi: "bedrock-converse-stream", + }); + expect(policy.repairToolUseResultPairing).toBe(true); + expect(policy.validateAnthropicTurns).toBe(true); + expect(policy.allowSyntheticToolResults).toBe(true); + expect(policy.sanitizeToolCallIds).toBe(true); + expect(policy.sanitizeMode).toBe("full"); + }); + it("keeps OpenRouter on its existing turn-validation path", () => { const policy = resolveTranscriptPolicy({ provider: "openrouter", diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 0672bf1e8409..3b1d6aa1db4d 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -55,12 +55,12 @@ function isOpenAiProvider(provider?: string | null): boolean { } function isAnthropicApi(modelApi?: string | null, provider?: string | null): boolean { - if (modelApi === "anthropic-messages") { + if (modelApi === "anthropic-messages" || modelApi === "bedrock-converse-stream") { return true; } const normalized = normalizeProviderId(provider ?? ""); // MiniMax now uses openai-completions API, not anthropic-messages - return normalized === "anthropic"; + return normalized === "anthropic" || normalized === "amazon-bedrock"; } function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean { From 58ce0a89ecf1d44e9e58452e9c7d2fb775f02f4e Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Mon, 23 Feb 2026 10:42:05 -0300 Subject: [PATCH 111/408] fix(cli): load plugin registry for configure and onboard commands (#17266) (cherry picked from commit 644badd40df6eb36847ee7baf36e02ae07bdac74) --- src/cli/program/preaction.test.ts | 20 ++++++++++++++++++++ src/cli/program/preaction.ts | 8 +++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index c583d2c83cf7..bf4184d362a9 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -80,6 +80,8 @@ describe("registerPreActionHooks", () => { program.command("update").action(async () => {}); program.command("channels").action(async () => {}); program.command("directory").action(async () => {}); + program.command("configure").action(async () => {}); + program.command("onboard").action(async () => {}); program .command("message") .command("send") @@ -125,6 +127,24 @@ describe("registerPreActionHooks", () => { expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1); }); + it("loads plugin registry for configure command", async () => { + await runCommand({ + parseArgv: ["configure"], + processArgv: ["node", "openclaw", "configure"], + }); + + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1); + }); + + it("loads plugin registry for onboard command", async () => { + await runCommand({ + parseArgv: ["onboard"], + processArgv: ["node", "openclaw", "onboard"], + }); + + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1); + }); + it("skips config guard for doctor and completion commands", async () => { await runCommand({ parseArgv: ["doctor"], diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 3e0580154bd1..6a9abc3e99ed 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -21,7 +21,13 @@ function setProcessTitleForCommand(actionCommand: Command) { } // Commands that need channel plugins loaded -const PLUGIN_REQUIRED_COMMANDS = new Set(["message", "channels", "directory"]); +const PLUGIN_REQUIRED_COMMANDS = new Set([ + "message", + "channels", + "directory", + "configure", + "onboard", +]); function getRootCommand(command: Command): Command { let current = command; From 75969ed5c499a1a45d9cfcbaaf999567fc4d9c2e Mon Sep 17 00:00:00 2001 From: Marc Gratch Date: Mon, 23 Feb 2026 11:43:52 -0600 Subject: [PATCH 112/408] fix(plugins): pass session context to before_compaction hook in subscribe handler The handleAutoCompactionStart handler was calling runBeforeCompaction with only messageCount and an empty hook context. Plugins receiving this hook could not identify the session or snapshot the transcript during auto-compaction. The other call site in compact.ts already passes the full payload (messages, sessionFile, sessionKey). This aligns the subscribe handler to do the same using ctx.params.session and ctx.params.sessionKey. (cherry picked from commit 318a19d1a1a428ff1be2e03f51777c3829c6e322) --- .../pi-embedded-subscribe.handlers.compaction.ts | 6 +++++- src/plugins/wired-hooks-compaction.test.ts | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/agents/pi-embedded-subscribe.handlers.compaction.ts b/src/agents/pi-embedded-subscribe.handlers.compaction.ts index a9dda4110e00..a8072bf2e1a8 100644 --- a/src/agents/pi-embedded-subscribe.handlers.compaction.ts +++ b/src/agents/pi-embedded-subscribe.handlers.compaction.ts @@ -24,8 +24,12 @@ export function handleAutoCompactionStart(ctx: EmbeddedPiSubscribeContext) { .runBeforeCompaction( { messageCount: ctx.params.session.messages?.length ?? 0, + messages: ctx.params.session.messages, + sessionFile: ctx.params.session.sessionFile, + }, + { + sessionKey: ctx.params.sessionKey, }, - {}, ) .catch((err) => { ctx.log.warn(`before_compaction hook failed: ${String(err)}`); diff --git a/src/plugins/wired-hooks-compaction.test.ts b/src/plugins/wired-hooks-compaction.test.ts index 2292d95b7609..05e63a2b2f93 100644 --- a/src/plugins/wired-hooks-compaction.test.ts +++ b/src/plugins/wired-hooks-compaction.test.ts @@ -41,7 +41,11 @@ describe("compaction hook wiring", () => { hookMocks.runner.hasHooks.mockReturnValue(true); const ctx = { - params: { runId: "r1", session: { messages: [1, 2, 3] } }, + params: { + runId: "r1", + sessionKey: "agent:main:web-abc123", + session: { messages: [1, 2, 3], sessionFile: "/tmp/test.jsonl" }, + }, state: { compactionInFlight: false }, log: { debug: vi.fn(), warn: vi.fn() }, incrementCompactionCount: vi.fn(), @@ -53,10 +57,16 @@ describe("compaction hook wiring", () => { expect(hookMocks.runner.runBeforeCompaction).toHaveBeenCalledTimes(1); const beforeCalls = hookMocks.runner.runBeforeCompaction.mock.calls as unknown as Array< - [unknown] + [unknown, unknown] >; - const event = beforeCalls[0]?.[0] as { messageCount?: number } | undefined; + const event = beforeCalls[0]?.[0] as + | { messageCount?: number; messages?: unknown[]; sessionFile?: string } + | undefined; expect(event?.messageCount).toBe(3); + expect(event?.messages).toEqual([1, 2, 3]); + expect(event?.sessionFile).toBe("/tmp/test.jsonl"); + const hookCtx = beforeCalls[0]?.[1] as { sessionKey?: string } | undefined; + expect(hookCtx?.sessionKey).toBe("agent:main:web-abc123"); }); it("calls runAfterCompaction when willRetry is false", () => { From 8c8374defa4d670e62236ba2a161ff009462b1f8 Mon Sep 17 00:00:00 2001 From: chilu18 Date: Mon, 23 Feb 2026 19:16:57 +0000 Subject: [PATCH 113/408] fix(cron): treat embedded error payloads as run failures (cherry picked from commit 50fd31c070e8b466db6d81c70b285fd631df1c05) --- ....uses-last-non-empty-agent-text-as.test.ts | 27 +++++++++++++++++-- src/cron/isolated-agent/run.ts | 26 ++++++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts index abb27177a54f..353d92e1b858 100644 --- a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts +++ b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts @@ -27,9 +27,9 @@ function makeDeps(): CliDeps { }; } -function mockEmbeddedTexts(texts: string[]) { +function mockEmbeddedPayloads(payloads: Array<{ text?: string; isError?: boolean }>) { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: texts.map((text) => ({ text })), + payloads, meta: { durationMs: 5, agentMeta: { sessionId: "s", provider: "p", model: "m" }, @@ -37,6 +37,10 @@ function mockEmbeddedTexts(texts: string[]) { }); } +function mockEmbeddedTexts(texts: string[]) { + mockEmbeddedPayloads(texts.map((text) => ({ text }))); +} + function mockEmbeddedOk() { mockEmbeddedTexts(["ok"]); } @@ -174,6 +178,25 @@ describe("runCronIsolatedAgentTurn", () => { }); }); + it("returns error when embedded run payload is marked as error", async () => { + await withTempHome(async (home) => { + mockEmbeddedPayloads([ + { + text: "⚠️ 🛠️ Exec failed: /bin/bash: line 1: python: command not found", + isError: true, + }, + ]); + const { res } = await runCronTurn(home, { + jobPayload: DEFAULT_AGENT_TURN_PAYLOAD, + mockTexts: null, + }); + + expect(res.status).toBe("error"); + expect(res.error).toContain("command not found"); + expect(res.summary).toContain("Exec failed"); + }); + }); + it("passes resolved agentDir to runEmbeddedPiAgent", async () => { await withTempHome(async (home) => { const { res } = await runCronTurn(home, { diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index ea6c819e2537..bfc37d48249f 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -543,6 +543,25 @@ export async function runCronIsolatedAgentTurn(params: { (deliveryPayload?.mediaUrls?.length ?? 0) > 0 || Object.keys(deliveryPayload?.channelData ?? {}).length > 0; const deliveryBestEffort = resolveCronDeliveryBestEffort(params.job); + const hasErrorPayload = payloads.some((payload) => payload?.isError === true); + const lastErrorPayloadText = [...payloads] + .toReversed() + .find((payload) => payload?.isError === true && Boolean(payload?.text?.trim())) + ?.text?.trim(); + const embeddedRunError = hasErrorPayload + ? (lastErrorPayloadText ?? "cron isolated run returned an error payload") + : undefined; + const resolveRunOutcome = (params?: { delivered?: boolean }) => + withRunSession({ + status: hasErrorPayload ? "error" : "ok", + ...(hasErrorPayload + ? { error: embeddedRunError ?? "cron isolated run returned an error payload" } + : {}), + summary, + outputText, + delivered: params?.delivered, + ...telemetry, + }); // Skip delivery for heartbeat-only responses (HEARTBEAT_OK with no real content). const ackMaxChars = resolveHeartbeatAckMaxChars(agentCfg); @@ -586,11 +605,14 @@ export async function runCronIsolatedAgentTurn(params: { withRunSession, }); if (deliveryResult.result) { - return deliveryResult.result; + if (!hasErrorPayload || deliveryResult.result.status !== "ok") { + return deliveryResult.result; + } + return resolveRunOutcome({ delivered: deliveryResult.result.delivered }); } const delivered = deliveryResult.delivered; summary = deliveryResult.summary; outputText = deliveryResult.outputText; - return withRunSession({ status: "ok", summary, outputText, delivered, ...telemetry }); + return resolveRunOutcome({ delivered }); } From 424ba72cad2402f5d0556fcf8456ccad4904a7b0 Mon Sep 17 00:00:00 2001 From: chilu18 Date: Mon, 23 Feb 2026 20:18:26 +0000 Subject: [PATCH 114/408] fix(config): add actionable guidance for dmPolicy open allowFrom mismatch (cherry picked from commit d3bfbdec5dc5c85305caa0f129f5d4b3c504f559) --- src/config/io.ts | 27 ++++++++++++++++++++++++++- src/config/io.write-config.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/config/io.ts b/src/config/io.ts index 8dbcf10936c2..01e691f1e600 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -72,6 +72,9 @@ const SHELL_ENV_EXPECTED_KEYS = [ "OPENCLAW_GATEWAY_PASSWORD", ]; +const OPEN_DM_POLICY_ALLOW_FROM_RE = + /^(?[a-z0-9_.-]+)\s*=\s*"open"\s+requires\s+(?[a-z0-9_.-]+)(?:\s+\(or\s+[a-z0-9_.-]+\))?\s+to include "\*"$/i; + const CONFIG_AUDIT_LOG_FILENAME = "config-audit.jsonl"; const loggedInvalidConfigs = new Set(); @@ -137,6 +140,27 @@ function hashConfigRaw(raw: string | null): string { .digest("hex"); } +function formatConfigValidationFailure(pathLabel: string, issueMessage: string): string { + const match = issueMessage.match(OPEN_DM_POLICY_ALLOW_FROM_RE); + const policyPath = match?.groups?.policyPath?.trim(); + const allowPath = match?.groups?.allowPath?.trim(); + if (!policyPath || !allowPath) { + return `Config validation failed: ${pathLabel}: ${issueMessage}`; + } + + return [ + `Config validation failed: ${pathLabel}`, + "", + `Configuration mismatch: ${policyPath} is "open", but ${allowPath} does not include "*".`, + "", + "Fix with:", + ` openclaw config set ${allowPath} '["*"]'`, + "", + "Or switch policy:", + ` openclaw config set ${policyPath} "pairing"`, + ].join("\n"); +} + function isNumericPathSegment(raw: string): boolean { return /^[0-9]+$/.test(raw); } @@ -1019,7 +1043,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { if (!validated.ok) { const issue = validated.issues[0]; const pathLabel = issue?.path ? issue.path : ""; - throw new Error(`Config validation failed: ${pathLabel}: ${issue?.message ?? "invalid"}`); + const issueMessage = issue?.message ?? "invalid"; + throw new Error(formatConfigValidationFailure(pathLabel, issueMessage)); } if (validated.warnings.length > 0) { const details = validated.warnings diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index d8ac2bbc2805..20a9ffc020de 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -125,6 +125,32 @@ describe("config io write", () => { }); }); + it('shows actionable guidance for dmPolicy="open" without wildcard allowFrom', async () => { + await withTempHome("openclaw-config-io-", async (home) => { + const io = createConfigIO({ + env: {} as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + + const invalidConfig = { + channels: { + telegram: { + dmPolicy: "open", + allowFrom: [], + }, + }, + }; + + await expect(io.writeConfigFile(invalidConfig)).rejects.toThrow( + "openclaw config set channels.telegram.allowFrom '[\"*\"]'", + ); + await expect(io.writeConfigFile(invalidConfig)).rejects.toThrow( + 'openclaw config set channels.telegram.dmPolicy "pairing"', + ); + }); + }); + it("honors explicit unset paths when schema defaults would otherwise reappear", async () => { await withTempHome("openclaw-config-io-", async (home) => { const { configPath, io, snapshot } = await writeConfigAndCreateIo({ From 252079f0013467169def8be63dd062a360280fe0 Mon Sep 17 00:00:00 2001 From: Ben Marvell Date: Mon, 23 Feb 2026 14:12:11 +0000 Subject: [PATCH 115/408] fix(agents): repair orphaned tool results for OpenAI after history truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit repairToolUseResultPairing was gated behind !isOpenAi, skipping orphaned tool_result cleanup for OpenAI providers. When limitHistoryTurns truncated conversation history, tool_result messages whose matching tool_call was before the truncation point survived and were sent as function_call_output items with stale call_id references. OpenAI rejects these with: "No tool call found for function call output with call_id ..." Enable the repair universally — all providers need it after truncation. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 97b065aa6e56fff97414bee26a6b6fc5a33f019a) --- src/agents/transcript-policy.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 3b1d6aa1db4d..baa12eda96ab 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -108,7 +108,10 @@ export function resolveTranscriptPolicy(params: { : sanitizeToolCallIds ? "strict" : undefined; - const repairToolUseResultPairing = isGoogle || isAnthropic; + // All providers need orphaned tool_result repair after history truncation. + // OpenAI rejects function_call_output items whose call_id has no matching + // function_call in the conversation, so the repair must run universally. + const repairToolUseResultPairing = true; const sanitizeThoughtSignatures = isOpenRouterGemini || isGoogle ? { allowBase64Only: true, includeCamelCase: true } : undefined; @@ -116,7 +119,7 @@ export function resolveTranscriptPolicy(params: { sanitizeMode: isOpenAi ? "images-only" : needsNonImageSanitize ? "full" : "images-only", sanitizeToolCallIds: !isOpenAi && sanitizeToolCallIds, toolCallIdMode, - repairToolUseResultPairing: !isOpenAi && repairToolUseResultPairing, + repairToolUseResultPairing, preserveSignatures: false, sanitizeThoughtSignatures: isOpenAi ? undefined : sanitizeThoughtSignatures, sanitizeThinkingSignatures: false, From eae13d9367676c278f5e904fbfe715d81f55521d Mon Sep 17 00:00:00 2001 From: Ben Marvell Date: Mon, 23 Feb 2026 14:37:56 +0000 Subject: [PATCH 116/408] test(agents): update test to match universal tool-result repair for OpenAI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous test asserted that OpenAI-responses sessions would NOT get synthetic tool results for orphaned tool calls. With repairToolUseResultPairing now running universally, the correct behavior is that orphaned tool calls get a synthetic tool_result — matching what OpenAI actually requires. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 2edb0ffe0bf96e9e415c03458ff9cee6bf29bcbe) --- .../pi-embedded-runner.sanitize-session-history.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index e9cd5065d3dd..6e401b92e0aa 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -298,7 +298,7 @@ describe("sanitizeSessionHistory", () => { expect(result[1]?.role).toBe("assistant"); }); - it("does not synthesize tool results for openai-responses", async () => { + it("synthesizes missing tool results for openai-responses after repair", async () => { const messages = [ { role: "assistant", @@ -314,8 +314,11 @@ describe("sanitizeSessionHistory", () => { sessionId: TEST_SESSION_ID, }); - expect(result).toHaveLength(1); + // repairToolUseResultPairing now runs for all providers (including OpenAI) + // to fix orphaned function_call_output items that OpenAI would reject. + expect(result).toHaveLength(2); expect(result[0]?.role).toBe("assistant"); + expect(result[1]?.role).toBe("toolResult"); }); it("drops malformed tool calls missing input or arguments", async () => { From bc52d4a459b1d546e87981fb7a1bc0e163bbdb71 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Tue, 24 Feb 2026 04:36:20 +0100 Subject: [PATCH 117/408] fix(openrouter): skip reasoning effort injection for 'auto' routing model The 'auto' model on OpenRouter dynamically routes to any underlying model OpenRouter selects, including reasoning-required endpoints. Previously, OpenClaw would unconditionally inject `reasoning.effort: "none"` into every request when the thinking level was "off", which causes a 400 error on models where reasoning is mandatory and cannot be disabled. Root cause: - openrouter/auto has reasoning: false in the built-in catalog - With thinking level "off", createOpenRouterWrapper injects `reasoning: { effort: "none" }` via mapThinkingLevelToOpenRouterReasoningEffort - For any OpenRouter-routed model that requires reasoning this results in: "400 Reasoning is mandatory for this endpoint and cannot be disabled" - The reasoning: false is then persisted back to models.json on every ensureOpenClawModelsJson call, so manually removing it has no lasting effect Fix: - In applyExtraParamsToAgent, when provider is "openrouter" and the model id is "auto", pass undefined as thinkingLevel to createOpenRouterWrapper so no reasoning.effort is injected at all, letting OpenRouter's upstream model handle it natively - Add an explanatory comment in buildOpenrouterProvider clarifying that the reasoning: false catalog value does NOT cause effort injection for "auto" Users who need explicit reasoning control should target a specific model id (e.g. openrouter/deepseek/deepseek-r1) rather than the auto router. Fixes #24851 (cherry picked from commit aa554397980972d917dece09ab03c4cc15f5d100) --- src/agents/models-config.providers.ts | 6 ++++++ src/agents/pi-embedded-runner/extra-params.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 3662ce9a3b1d..4f921b6dd81d 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -685,6 +685,12 @@ function buildOpenrouterProvider(): ProviderConfig { { id: OPENROUTER_DEFAULT_MODEL_ID, name: "OpenRouter Auto", + // reasoning: false here is a catalog default only; it does NOT cause + // `reasoning.effort: "none"` to be sent for the "auto" routing model. + // applyExtraParamsToAgent skips the reasoning effort injection for + // model id "auto" because it dynamically routes to any OpenRouter model + // (including ones where reasoning is mandatory and cannot be disabled). + // See: openclaw/openclaw#24851 reasoning: false, input: ["text", "image"], cost: OPENROUTER_DEFAULT_COST, diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 66b077af2329..0d88bdf08f33 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -546,7 +546,14 @@ export function applyExtraParamsToAgent( if (provider === "openrouter") { log.debug(`applying OpenRouter app attribution headers for ${provider}/${modelId}`); - agent.streamFn = createOpenRouterWrapper(agent.streamFn, thinkingLevel); + // "auto" is a dynamic routing model — we don't know which underlying model + // OpenRouter will select, and it may be a reasoning-required endpoint. + // Omit the thinkingLevel so we never inject `reasoning.effort: "none"`, + // which would cause a 400 on models where reasoning is mandatory. + // Users who need reasoning control should target a specific model ID. + // See: openclaw/openclaw#24851 + const openRouterThinkingLevel = modelId === "auto" ? undefined : thinkingLevel; + agent.streamFn = createOpenRouterWrapper(agent.streamFn, openRouterThinkingLevel); agent.streamFn = createOpenRouterSystemCacheWrapper(agent.streamFn); } From 83689fc83837ac80837705cfd4b81aae0214afeb Mon Sep 17 00:00:00 2001 From: Marco Di Dionisio Date: Mon, 23 Feb 2026 19:20:26 +0100 Subject: [PATCH 118/408] fix: include trusted-proxy in sharedAuthOk check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In trusted-proxy mode, sharedAuthResult is null because hasSharedAuth only triggers for token/password in connectParams.auth. But the primary auth (authResult) already validated the trusted-proxy — the connection came from a CIDR in trustedProxies with a valid userHeader. This IS shared auth semantically (the proxy vouches for identity), so operator connections should be able to skip device identity. Without this fix, trusted-proxy operator connections are rejected with "device identity required" because roleCanSkipDeviceIdentity() sees sharedAuthOk=false. (cherry picked from commit e87048a6a650d391e1eb5704546eb49fac5f0091) --- src/gateway/server/ws-connection/auth-context.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/gateway/server/ws-connection/auth-context.ts b/src/gateway/server/ws-connection/auth-context.ts index d5e98dfd533d..cb7977722889 100644 --- a/src/gateway/server/ws-connection/auth-context.ts +++ b/src/gateway/server/ws-connection/auth-context.ts @@ -133,9 +133,13 @@ export async function resolveConnectAuthState(params: { // primary auth flow (or deferred for device-token candidates). rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, })); + // Trusted-proxy auth is semantically shared: the proxy vouches for identity, + // no per-device credential needed. Include it so operator connections + // can skip device identity via roleCanSkipDeviceIdentity(). const sharedAuthOk = - sharedAuthResult?.ok === true && - (sharedAuthResult.method === "token" || sharedAuthResult.method === "password"); + (sharedAuthResult?.ok === true && + (sharedAuthResult.method === "token" || sharedAuthResult.method === "password")) || + (authResult.ok && authResult.method === "trusted-proxy"); return { authResult, From a7518b75894ed4745953fb2b1371f7a9a2c3e445 Mon Sep 17 00:00:00 2001 From: Shennan Date: Tue, 24 Feb 2026 01:48:51 +0800 Subject: [PATCH 119/408] fix(feishu): pass parentPeer for topic session binding inheritance (cherry picked from commit bddeb1fd95d10cf18da9dca129b58828eae84cba) --- extensions/feishu/src/bot.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 91d390ac04dc..f18658e62b50 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -720,10 +720,10 @@ export async function handleFeishuMessage(params: { // When topicSessionMode is enabled, messages within a topic (identified by root_id) // get a separate session from the main group chat. let peerId = isGroup ? ctx.chatId : ctx.senderOpenId; + let topicSessionMode: "enabled" | "disabled" = "disabled"; if (isGroup && ctx.rootId) { const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId }); - const topicSessionMode = - groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled"; + topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled"; if (topicSessionMode === "enabled") { // Use chatId:topic:rootId as peer ID for topic-scoped sessions peerId = `${ctx.chatId}:topic:${ctx.rootId}`; @@ -739,6 +739,14 @@ export async function handleFeishuMessage(params: { kind: isGroup ? "group" : "direct", id: peerId, }, + // Add parentPeer for binding inheritance in topic mode + parentPeer: + isGroup && ctx.rootId && topicSessionMode === "enabled" + ? { + kind: "group", + id: ctx.chatId, + } + : null, }); // Dynamic agent creation for DM users From b9e587fb63ddec9d429ffd1c6fa68a55be59b9ec Mon Sep 17 00:00:00 2001 From: Workweaver Ralph Date: Tue, 24 Feb 2026 06:33:17 +0530 Subject: [PATCH 120/408] fix(tui): guard sendMessage when disconnected; reset readyPromise on close (cherry picked from commit df827c3eef34ca02cfe5c57a1eabcd9c8e5a4ec1) --- src/tui/gateway-chat.ts | 4 ++++ src/tui/tui-command-handlers.ts | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 5cbec2e02990..f55bbf5f3543 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -146,6 +146,10 @@ export class GatewayChatClient { }); }, onClose: (_code, reason) => { + // Reset so waitForReady() blocks again until the next successful reconnect. + this.readyPromise = new Promise((resolve) => { + this.resolveReady = resolve; + }); this.onDisconnected?.(reason); }, onGap: (info) => { diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 4e5a56f6238a..989c942beb6e 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -456,6 +456,12 @@ export function createCommandHandlers(context: CommandHandlerContext) { }; const sendMessage = async (text: string) => { + if (!state.isConnected) { + chatLog.addSystem("not connected to gateway — message not sent"); + setActivityStatus("disconnected"); + tui.requestRender(); + return; + } try { chatLog.addUser(text); tui.requestRender(); From 7d76c241f89c5fbd4eca173e002dc24c765e07ab Mon Sep 17 00:00:00 2001 From: User Date: Tue, 24 Feb 2026 11:11:41 +0800 Subject: [PATCH 121/408] fix: suppress reasoning payloads from generic channel dispatch path When reasoningLevel is 'on', reasoning content was being sent as a visible message to WhatsApp and other non-Telegram channels via two paths: 1. Block reply: emitted via onBlockReply in handleMessageEnd 2. Final payloads: added to replyItems in buildEmbeddedRunPayloads Telegram has its own dispatch path (bot-message-dispatch.ts) that splits reasoning into a dedicated lane and handles suppression. The generic dispatch-from-config.ts path used by WhatsApp, web, etc. had no such filtering. Fix: - Add isReasoning?: boolean flag to ReplyPayload - Tag reasoning payloads at both emission points - Filter isReasoning payloads in dispatch-from-config.ts for both block reply and final reply paths Telegram is unaffected: it uses its own deliver callback that detects reasoning via the 'Reasoning:\n' prefix and routes to a separate lane. Fixes #24954 --- src/agents/pi-embedded-runner/run/payloads.ts | 2 +- ...pi-embedded-subscribe.handlers.messages.ts | 2 +- .../reply/dispatch-from-config.test.ts | 43 +++++++++++++++++++ src/auto-reply/reply/dispatch-from-config.ts | 11 +++++ src/auto-reply/types.ts | 3 ++ 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index 7b3d40c5d009..d4ee6dc0763e 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -187,7 +187,7 @@ export function buildEmbeddedRunPayloads(params: { ? formatReasoningMessage(extractAssistantThinking(params.lastAssistant)) : ""; if (reasoningText) { - replyItems.push({ text: reasoningText }); + replyItems.push({ text: reasoningText, isReasoning: true }); } const fallbackAnswerText = params.lastAssistant ? extractAssistantText(params.lastAssistant) : ""; diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index 845ded9f9b9e..a32c9fdf2195 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -339,7 +339,7 @@ export function handleMessageEnd( return; } ctx.state.lastReasoningSent = formattedReasoning; - void onBlockReply?.({ text: formattedReasoning }); + void onBlockReply?.({ text: formattedReasoning, isReasoning: true }); }; if (shouldEmitReasoningBeforeAnswer) { diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 2a69f506a7f5..bd1715bf5117 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -538,4 +538,47 @@ describe("dispatchReplyFromConfig", () => { }), ); }); + + it("suppresses isReasoning payloads from final replies (WhatsApp channel)", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ Provider: "whatsapp" }); + const replyResolver = async () => + [ + { text: "Reasoning:\n_thinking..._", isReasoning: true }, + { text: "The answer is 42" }, + ] satisfies ReplyPayload[]; + await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); + const finalCalls = (dispatcher.sendFinalReply as ReturnType).mock.calls; + expect(finalCalls).toHaveLength(1); + expect(finalCalls[0][0]).toMatchObject({ text: "The answer is 42" }); + }); + + it("suppresses isReasoning payloads from block replies (generic dispatch path)", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ Provider: "whatsapp" }); + const blockReplySentTexts: string[] = []; + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + ): Promise => { + // Simulate block reply with reasoning payload + await opts?.onBlockReply?.({ text: "Reasoning:\n_thinking..._", isReasoning: true }); + await opts?.onBlockReply?.({ text: "The answer is 42" }); + return { text: "The answer is 42" }; + }; + // Capture what actually gets dispatched as block replies + (dispatcher.sendBlockReply as ReturnType).mockImplementation( + (payload: ReplyPayload) => { + if (payload.text) { + blockReplySentTexts.push(payload.text); + } + return true; + }, + ); + await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); + expect(blockReplySentTexts).not.toContain("Reasoning:\n_thinking..._"); + expect(blockReplySentTexts).toContain("The answer is 42"); + }); }); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index e4e66c16a574..96989ff98ea9 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -363,6 +363,12 @@ export async function dispatchReplyFromConfig(params: { }, onBlockReply: (payload: ReplyPayload, context) => { const run = async () => { + // Suppress reasoning payloads — channels using this generic dispatch + // path (WhatsApp, web, etc.) do not have a dedicated reasoning lane. + // Telegram has its own dispatch path that handles reasoning splitting. + if (payload.isReasoning) { + return; + } // Accumulate block text for TTS generation after streaming if (payload.text) { if (accumulatedBlockText.length > 0) { @@ -396,6 +402,11 @@ export async function dispatchReplyFromConfig(params: { let queuedFinal = false; let routedFinalCount = 0; for (const reply of replies) { + // Suppress reasoning payloads from channel delivery — channels using this + // generic dispatch path do not have a dedicated reasoning lane. + if (reply.isReasoning) { + continue; + } const ttsReply = await maybeApplyTtsToPayload({ payload: reply, cfg, diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 839fac559774..f522e31042fa 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -66,6 +66,9 @@ export type ReplyPayload = { /** Send audio as voice message (bubble) instead of audio file. Defaults to false. */ audioAsVoice?: boolean; isError?: boolean; + /** Marks this payload as a reasoning/thinking block. Channels that do not + * have a dedicated reasoning lane (e.g. WhatsApp, web) should suppress it. */ + isReasoning?: boolean; /** Channel-specific payload data (per-channel envelope). */ channelData?: Record; }; From d427d09b5ee041e4bd90fc351993017fa6114030 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:50:27 +0000 Subject: [PATCH 122/408] fix: align reasoning payload typing for #24991 (thanks @stakeswky) --- CHANGELOG.md | 1 + src/agents/pi-embedded-payloads.ts | 1 + src/agents/pi-embedded-runner/run/payloads.ts | 2 ++ 3 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1efde75b9f8..41d53e20462f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg. - WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. - WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. +- Channels/Reasoning: suppress reasoning/thinking payload segments in the shared channel dispatch path so non-Telegram channels (including WhatsApp and Web) no longer emit internal reasoning blocks as user-visible replies. (#24991) Thanks @stakeswky. - Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. diff --git a/src/agents/pi-embedded-payloads.ts b/src/agents/pi-embedded-payloads.ts index 1be29b5a3afe..1186111db107 100644 --- a/src/agents/pi-embedded-payloads.ts +++ b/src/agents/pi-embedded-payloads.ts @@ -2,6 +2,7 @@ export type BlockReplyPayload = { text?: string; mediaUrls?: string[]; audioAsVoice?: boolean; + isReasoning?: boolean; replyToId?: string; replyToTag?: boolean; replyToCurrent?: boolean; diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index d4ee6dc0763e..c3c878454513 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -108,6 +108,7 @@ export function buildEmbeddedRunPayloads(params: { mediaUrls?: string[]; replyToId?: string; isError?: boolean; + isReasoning?: boolean; audioAsVoice?: boolean; replyToTag?: boolean; replyToCurrent?: boolean; @@ -116,6 +117,7 @@ export function buildEmbeddedRunPayloads(params: { text: string; media?: string[]; isError?: boolean; + isReasoning?: boolean; audioAsVoice?: boolean; replyToId?: string; replyToTag?: boolean; From 19d0ddc679d54f13e5af1ff250b85fb46f21b485 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:05:03 +0000 Subject: [PATCH 123/408] fix: regenerate protocol swift models for nodeId (#24991) (thanks @stakeswky) --- apps/macos/Sources/OpenClawProtocol/GatewayModels.swift | 4 ++++ .../OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index af7b1ccafdc2..4e766514defc 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -2806,6 +2806,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public let id: String? public let command: String public let cwd: AnyCodable? + public let nodeid: AnyCodable? public let host: AnyCodable? public let security: AnyCodable? public let ask: AnyCodable? @@ -2819,6 +2820,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { id: String?, command: String, cwd: AnyCodable?, + nodeid: AnyCodable?, host: AnyCodable?, security: AnyCodable?, ask: AnyCodable?, @@ -2831,6 +2833,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { self.id = id self.command = command self.cwd = cwd + self.nodeid = nodeid self.host = host self.security = security self.ask = ask @@ -2845,6 +2848,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { case id case command case cwd + case nodeid = "nodeId" case host case security case ask diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index af7b1ccafdc2..4e766514defc 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2806,6 +2806,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public let id: String? public let command: String public let cwd: AnyCodable? + public let nodeid: AnyCodable? public let host: AnyCodable? public let security: AnyCodable? public let ask: AnyCodable? @@ -2819,6 +2820,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { id: String?, command: String, cwd: AnyCodable?, + nodeid: AnyCodable?, host: AnyCodable?, security: AnyCodable?, ask: AnyCodable?, @@ -2831,6 +2833,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { self.id = id self.command = command self.cwd = cwd + self.nodeid = nodeid self.host = host self.security = security self.ask = ask @@ -2845,6 +2848,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { case id case command case cwd + case nodeid = "nodeId" case host case security case ask From 2880fb3cb89a4bcb54e6900ce82a0b51e275284d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:07:44 +0000 Subject: [PATCH 124/408] fix: sync lockfile for diagnostics-otel deps (#24991) (thanks @stakeswky) --- pnpm-lock.yaml | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85fe19921d7a..9eb4bc69db0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -322,12 +322,6 @@ importers: specifier: workspace:* version: link:../.. - extensions/google-antigravity-auth: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/google-gemini-cli-auth: devDependencies: openclaw: @@ -6890,7 +6884,7 @@ snapshots: '@larksuiteoapi/node-sdk@1.59.0': dependencies: - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) lodash.identity: 3.0.0 lodash.merge: 4.6.2 lodash.pickby: 4.6.0 @@ -6906,7 +6900,7 @@ snapshots: dependencies: '@types/node': 24.10.13 optionalDependencies: - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) transitivePeerDependencies: - debug @@ -7095,7 +7089,7 @@ snapshots: '@azure/core-auth': 1.10.1 '@azure/msal-node': 5.0.4 '@microsoft/agents-activity': 1.3.1 - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) jsonwebtoken: 9.0.3 jwks-rsa: 3.2.2 object-path: 0.11.8 @@ -7997,7 +7991,7 @@ snapshots: '@slack/types': 2.20.0 '@slack/web-api': 7.14.1 '@types/express': 5.0.6 - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) express: 5.2.1 path-to-regexp: 8.3.0 raw-body: 3.0.2 @@ -8043,7 +8037,7 @@ snapshots: '@slack/types': 2.20.0 '@types/node': 25.3.0 '@types/retry': 0.12.0 - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) eventemitter3: 5.0.4 form-data: 2.5.4 is-electron: 2.2.2 @@ -8935,14 +8929,6 @@ snapshots: aws4@1.13.2: {} - axios@1.13.5: - dependencies: - follow-redirects: 1.15.11 - form-data: 2.5.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - axios@1.13.5(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) @@ -9512,8 +9498,6 @@ snapshots: flatbuffers@24.12.23: {} - follow-redirects@1.15.11: {} - follow-redirects@1.15.11(debug@4.4.3): optionalDependencies: debug: 4.4.3 From cb450fd31f0858601de9b1254db525dd8f022da3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:09:26 +0000 Subject: [PATCH 125/408] fix: align lockfile with diagnostics-otel proto deps (#24991) (thanks @stakeswky) --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9eb4bc69db0a..a8c7b81bb33b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,13 +268,13 @@ importers: '@opentelemetry/api-logs': specifier: ^0.212.0 version: 0.212.0 - '@opentelemetry/exporter-logs-otlp-http': + '@opentelemetry/exporter-logs-otlp-proto': specifier: ^0.212.0 version: 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': + '@opentelemetry/exporter-metrics-otlp-proto': specifier: ^0.212.0 version: 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http': + '@opentelemetry/exporter-trace-otlp-proto': specifier: ^0.212.0 version: 0.212.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': From d3ecc234da1729b19f7c9cd427aff0fb0d3ab15f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:19:12 +0000 Subject: [PATCH 126/408] test: align flaky CI expectations after main changes (#24991) (thanks @stakeswky) --- src/agents/sandbox/fs-bridge.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts index ca4dd9d62bb0..f1d72be03b6c 100644 --- a/src/agents/sandbox/fs-bridge.test.ts +++ b/src/agents/sandbox/fs-bridge.test.ts @@ -118,9 +118,11 @@ describe("sandbox fs bridge shell compatibility", () => { const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fs-bridge-")); const workspaceDir = path.join(stateDir, "workspace"); const outsideDir = path.join(stateDir, "outside"); + const outsideFile = path.join(outsideDir, "secret.txt"); await fs.mkdir(workspaceDir, { recursive: true }); await fs.mkdir(outsideDir, { recursive: true }); - await fs.symlink(path.join(outsideDir, "secret.txt"), path.join(workspaceDir, "link.txt")); + await fs.writeFile(outsideFile, "classified"); + await fs.symlink(outsideFile, path.join(workspaceDir, "link.txt")); const bridge = createSandboxFsBridge({ sandbox: createSandbox({ From 5ac70b36a4b6303fc1eff5f04a2737af3e729a23 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:33:53 +0000 Subject: [PATCH 127/408] test: make shell-env trust-path test platform-safe (#24991) (thanks @stakeswky) --- src/infra/shell-env.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/infra/shell-env.test.ts b/src/infra/shell-env.test.ts index 644948b03c9a..1696028b39da 100644 --- a/src/infra/shell-env.test.ts +++ b/src/infra/shell-env.test.ts @@ -199,8 +199,11 @@ describe("shell env fallback", () => { }); it("uses SHELL when it is explicitly registered in /etc/shells", () => { - withEtcShells(["/bin/sh", "/usr/bin/zsh-trusted"], () => { - const trustedShell = "/usr/bin/zsh-trusted"; + const trustedShell = + process.platform === "win32" + ? "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" + : "/usr/bin/zsh-trusted"; + withEtcShells(["/bin/sh", trustedShell], () => { const { res, exec } = runShellEnvFallbackForShell(trustedShell); expect(res.ok).toBe(true); From 1298bd4e1bdb7cd28dea6ddd8e3de7f2f43be36d Mon Sep 17 00:00:00 2001 From: justinhuangcode Date: Mon, 23 Feb 2026 17:41:12 +0000 Subject: [PATCH 128/408] fix(matrix): skip reasoning-only messages in reply delivery When `includeReasoning` is active (or `reasoningLevel` falls back to the model default), the agent emits reasoning blocks as separate reply payloads prefixed with "Reasoning:\n". Matrix has no dedicated reasoning lane, so these internal thinking traces leak into the chat as regular user-visible messages. Filter out pure-reasoning payloads (those starting with "Reasoning:\n" or a `` tag) before delivery so internal reasoning never reaches the Matrix room. Fixes #24411 Co-Authored-By: Claude Opus 4.6 --- .../matrix/src/matrix/monitor/replies.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index 643e95cd4134..c86c7dde688c 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -41,6 +41,11 @@ export async function deliverMatrixReplies(params: { params.runtime.error?.("matrix reply missing text/media"); continue; } + // Skip pure reasoning messages so internal thinking traces are never delivered. + if (reply.text && isReasoningOnlyMessage(reply.text)) { + logVerbose("matrix reply is reasoning-only; skipping"); + continue; + } const replyToIdRaw = reply.replyToId?.trim(); const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; const rawText = reply.text ?? ""; @@ -98,3 +103,22 @@ export async function deliverMatrixReplies(params: { } } } + +const REASONING_PREFIX = "Reasoning:\n"; +const THINKING_TAG_RE = /^\s*<\s*(?:think(?:ing)?|thought|antthinking)\b/i; + +/** + * Detect messages that contain only reasoning/thinking content and no user-facing answer. + * These are emitted by the agent when `includeReasoning` is active but should not + * be forwarded to channels that do not support a dedicated reasoning lane. + */ +function isReasoningOnlyMessage(text: string): boolean { + const trimmed = text.trim(); + if (trimmed.startsWith(REASONING_PREFIX)) { + return true; + } + if (THINKING_TAG_RE.test(trimmed)) { + return true; + } + return false; +} From 0ded77ca7d0003ae2210f506d5cababf1f4b8902 Mon Sep 17 00:00:00 2001 From: justinhuangcode Date: Mon, 23 Feb 2026 20:06:07 +0000 Subject: [PATCH 129/408] test(matrix): add regression tests for reasoning-only reply filtering Verify that deliverMatrixReplies skips replies whose text starts with "Reasoning:\n" or opens with // tags, while still delivering all normal replies. Co-Authored-By: Claude Opus 4.6 --- .../matrix/src/matrix/monitor/replies.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/extensions/matrix/src/matrix/monitor/replies.test.ts b/extensions/matrix/src/matrix/monitor/replies.test.ts index 3dda8fac9b58..dfbfbabb8af2 100644 --- a/extensions/matrix/src/matrix/monitor/replies.test.ts +++ b/extensions/matrix/src/matrix/monitor/replies.test.ts @@ -108,6 +108,58 @@ describe("deliverMatrixReplies", () => { ); }); + it("skips reasoning-only replies with Reasoning prefix", async () => { + await deliverMatrixReplies({ + replies: [ + { text: "Reasoning:\nThe user wants X because Y.", replyToId: "r1" }, + { text: "Here is the answer.", replyToId: "r2" }, + ], + roomId: "room:reason", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "first", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Here is the answer."); + }); + + it("skips reasoning-only replies with thinking tags", async () => { + await deliverMatrixReplies({ + replies: [ + { text: "internal chain of thought", replyToId: "r1" }, + { text: " more reasoning ", replyToId: "r2" }, + { text: "hidden", replyToId: "r3" }, + { text: "Visible reply", replyToId: "r4" }, + ], + roomId: "room:tags", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "all", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Visible reply"); + }); + + it("delivers all replies when none are reasoning-only", async () => { + await deliverMatrixReplies({ + replies: [ + { text: "First answer", replyToId: "r1" }, + { text: "Second answer", replyToId: "r2" }, + ], + roomId: "room:normal", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "all", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2); + }); + it("suppresses replyToId when threadId is set", async () => { chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|")); From e8a4d5d9bd578509a2f497ec231d7d25167b8e9d Mon Sep 17 00:00:00 2001 From: justinhuangcode Date: Mon, 23 Feb 2026 17:30:59 +0000 Subject: [PATCH 130/408] fix(discord): strip reasoning tags from partial stream preview When streamMode is "partial", reasoning/thinking block content can leak into the Discord draft preview because the partial text is forwarded to the draft stream without filtering. Apply `stripReasoningTagsFromText` before updating the draft and skip pure-reasoning messages (those starting with "Reasoning:\n") so internal thinking traces never reach the user-visible preview. Fixes #24532 Co-Authored-By: Claude Opus 4.6 --- .../monitor/message-handler.process.ts | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 1c41fef76ec0..60966cff3ccc 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -30,6 +30,7 @@ import { convertMarkdownTables } from "../../markdown/tables.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../routing/session-key.js"; import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js"; +import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; import { truncateUtf16Safe } from "../../utils.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js"; @@ -485,7 +486,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) if (!draftStream || !text) { return; } - if (text === lastPartialText) { + // Strip reasoning/thinking tags that may leak through the stream. + const cleaned = stripReasoningTagsFromText(text, { mode: "strict", trim: "both" }); + // Skip pure-reasoning messages (e.g. "Reasoning:\n…") that contain no answer text. + if (!cleaned || cleaned.startsWith("Reasoning:\n")) { + return; + } + if (cleaned === lastPartialText) { return; } hasStreamedMessage = true; @@ -493,30 +500,30 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) // Keep the longer preview to avoid visible punctuation flicker. if ( lastPartialText && - lastPartialText.startsWith(text) && - text.length < lastPartialText.length + lastPartialText.startsWith(cleaned) && + cleaned.length < lastPartialText.length ) { return; } - lastPartialText = text; - draftStream.update(text); + lastPartialText = cleaned; + draftStream.update(cleaned); return; } - let delta = text; - if (text.startsWith(lastPartialText)) { - delta = text.slice(lastPartialText.length); + let delta = cleaned; + if (cleaned.startsWith(lastPartialText)) { + delta = cleaned.slice(lastPartialText.length); } else { // Streaming buffer reset (or non-monotonic stream). Start fresh. draftChunker?.reset(); draftText = ""; } - lastPartialText = text; + lastPartialText = cleaned; if (!delta) { return; } if (!draftChunker) { - draftText = text; + draftText = cleaned; draftStream.update(draftText); return; } From 6ea1607f1c5ec1d7dbee0c995f2e983f09234d15 Mon Sep 17 00:00:00 2001 From: justinhuangcode Date: Mon, 23 Feb 2026 20:07:30 +0000 Subject: [PATCH 131/408] test(discord): add regression tests for reasoning tag stripping in stream Verify that partial stream updates containing tags are stripped before reaching the draft preview, and that pure "Reasoning:\n" partials are suppressed entirely. Co-Authored-By: Claude Opus 4.6 --- .../monitor/message-handler.process.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 067273351db3..482f61cfc3fd 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -476,4 +476,49 @@ describe("processDiscordMessage draft streaming", () => { expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1); }); + + it("strips reasoning tags from partial stream updates", async () => { + const draftStream = createMockDraftStream(); + createDiscordDraftStream.mockReturnValueOnce(draftStream); + + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onPartialReply?.({ + text: "Let me think about this\nThe answer is 42", + }); + return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; + }); + + const ctx = await createBaseContext({ + discordConfig: { streamMode: "partial" }, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + const updates = draftStream.update.mock.calls.map((call) => call[0]); + for (const text of updates) { + expect(text).not.toContain(""); + } + }); + + it("skips pure-reasoning partial updates without updating draft", async () => { + const draftStream = createMockDraftStream(); + createDiscordDraftStream.mockReturnValueOnce(draftStream); + + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onPartialReply?.({ + text: "Reasoning:\nThe user asked about X so I need to consider Y", + }); + return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; + }); + + const ctx = await createBaseContext({ + discordConfig: { streamMode: "partial" }, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + expect(draftStream.update).not.toHaveBeenCalled(); + }); }); From 2d6d6797d81a2534bff1aa9ee4f3c0cd2dd18013 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:38:04 +0000 Subject: [PATCH 132/408] test: fix post-merge config and tui command-handler tests --- .../browser-cli-state.option-collisions.test.ts | 12 +++++++++++- src/config/io.write-config.test.ts | 3 ++- src/tui/tui-command-handlers.test.ts | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/cli/browser-cli-state.option-collisions.test.ts b/src/cli/browser-cli-state.option-collisions.test.ts index 45ec5c6a5c13..917c6c4551ea 100644 --- a/src/cli/browser-cli-state.option-collisions.test.ts +++ b/src/cli/browser-cli-state.option-collisions.test.ts @@ -85,7 +85,17 @@ describe("browser state option collisions", () => { it("resolves --url via parent when addGatewayClientOptions captures it", async () => { const program = createBrowserProgram({ withGatewayUrl: true }); await program.parseAsync( - ["browser", "--url", "ws://gw", "cookies", "set", "session", "abc", "--url", "https://example.com"], + [ + "browser", + "--url", + "ws://gw", + "cookies", + "set", + "session", + "abc", + "--url", + "https://example.com", + ], { from: "user" }, ); const call = mocks.callBrowserRequest.mock.calls.at(-1); diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 20a9ffc020de..19bb776b49ab 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { withTempHome } from "./home-env.test-harness.js"; import { createConfigIO } from "./io.js"; +import type { OpenClawConfig } from "./types.js"; describe("config io write", () => { const silentLogger = { @@ -140,7 +141,7 @@ describe("config io write", () => { allowFrom: [], }, }, - }; + } satisfies OpenClawConfig; await expect(io.writeConfigFile(invalidConfig)).rejects.toThrow( "openclaw config set channels.telegram.allowFrom '[\"*\"]'", diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index f9e4ca3e40ff..bb17cbed9a4e 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -9,6 +9,7 @@ function createHarness(params?: { resetSession?: ReturnType; loadHistory?: LoadHistoryMock; setActivityStatus?: SetActivityStatusMock; + isConnected?: boolean; }) { const sendChat = params?.sendChat ?? vi.fn().mockResolvedValue({ runId: "r1" }); const resetSession = params?.resetSession ?? vi.fn().mockResolvedValue({ ok: true }); @@ -27,6 +28,7 @@ function createHarness(params?: { state: { currentSessionKey: "agent:main:main", activeChatRunId: null, + isConnected: params?.isConnected ?? true, sessionInfo: {}, } as never, deliverDefault: false, @@ -126,4 +128,17 @@ describe("tui command handlers", () => { expect(addSystem).toHaveBeenCalledWith("send failed: Error: gateway down"); expect(setActivityStatus).toHaveBeenLastCalledWith("error"); }); + + it("reports disconnected status and skips gateway send when offline", async () => { + const { handleCommand, sendChat, addUser, addSystem, setActivityStatus } = createHarness({ + isConnected: false, + }); + + await handleCommand("/context"); + + expect(sendChat).not.toHaveBeenCalled(); + expect(addUser).not.toHaveBeenCalled(); + expect(addSystem).toHaveBeenCalledWith("not connected to gateway — message not sent"); + expect(setActivityStatus).toHaveBeenLastCalledWith("disconnected"); + }); }); From 31f2bf9519d15580e9252d9a872b70c4ee54dd31 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:39:29 +0000 Subject: [PATCH 133/408] test: fix gate regressions --- scripts/test-parallel.mjs | 21 +++++++++- ...nk-low-reasoning-capable-models-no.test.ts | 41 +++++++++---------- ....agent-contract-snapshot-endpoints.test.ts | 18 ++++---- src/cli/update-cli.test.ts | 8 ---- src/config/io.write-config.test.ts | 2 +- src/process/exec.test.ts | 4 +- 6 files changed, 53 insertions(+), 41 deletions(-) diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index cb7e950a5dab..0ec8d2fdc5f3 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -19,6 +19,25 @@ const unitIsolatedFilesRaw = [ "src/auto-reply/tool-meta.test.ts", "src/auto-reply/envelope.test.ts", "src/commands/auth-choice.test.ts", + // Process supervision + docker setup suites are stable but setup-heavy. + "src/process/supervisor/supervisor.test.ts", + "src/docker-setup.test.ts", + // Filesystem-heavy skills sync suite. + "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts", + // Real git hook integration test; keep signal, move off unit-fast critical path. + "test/git-hooks-pre-commit.test.ts", + // Setup-heavy doctor command suites; keep them off the unit-fast critical path. + "src/commands/doctor.warns-state-directory-is-missing.test.ts", + "src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts", + "src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts", + // Setup-heavy CLI update flow suite; move off unit-fast critical path. + "src/cli/update-cli.test.ts", + // Expensive schema build/bootstrap checks; keep coverage but run in isolated lane. + "src/config/schema.test.ts", + "src/config/schema.tags.test.ts", + // CLI smoke/agent flows are stable but setup-heavy. + "src/cli/program.smoke.test.ts", + "src/commands/agent.test.ts", "src/media/store.test.ts", "src/media/store.header-ext.test.ts", "src/web/media.test.ts", @@ -210,7 +229,7 @@ const defaultWorkerBudget = unit: Math.max(4, Math.min(14, Math.floor((localWorkers * 7) / 8))), unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)), extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), - gateway: Math.max(2, Math.min(4, Math.floor(localWorkers / 3))), + gateway: Math.max(2, Math.min(6, Math.floor(localWorkers / 2))), } : lowMemLocalHost ? { diff --git a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts index 27e41f414ac7..e3b6970a68e6 100644 --- a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts @@ -112,7 +112,7 @@ async function runReasoningDefaultCase(params: { describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); - it("shows /think defaults for reasoning and non-reasoning models", async () => { + it("covers /think status and reasoning defaults for reasoning and non-reasoning models", async () => { await withTempHome(async (home) => { await expectThinkStatusForReasoningModel({ home, @@ -125,6 +125,25 @@ describe("directive behavior", () => { expectedLevel: "off", }); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + + vi.mocked(runEmbeddedPiAgent).mockClear(); + + for (const scenario of [ + { + expectedThinkLevel: "low" as const, + expectedReasoningLevel: "off" as const, + }, + { + expectedThinkLevel: "off" as const, + expectedReasoningLevel: "on" as const, + thinkingDefault: "off" as const, + }, + ]) { + await runReasoningDefaultCase({ + home, + ...scenario, + }); + } }); }); it("renders model list and status variants across catalog/config combinations", async () => { @@ -282,26 +301,6 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); }); }); - it("applies reasoning defaults based on thinkingDefault configuration", async () => { - await withTempHome(async (home) => { - for (const scenario of [ - { - expectedThinkLevel: "low" as const, - expectedReasoningLevel: "off" as const, - }, - { - expectedThinkLevel: "off" as const, - expectedReasoningLevel: "on" as const, - thinkingDefault: "off" as const, - }, - ]) { - await runReasoningDefaultCase({ - home, - ...scenario, - }); - } - }); - }); it("passes elevated defaults when sender is approved", async () => { await withTempHome(async (home) => { mockEmbeddedTextResult("done"); diff --git a/src/browser/server.agent-contract-snapshot-endpoints.test.ts b/src/browser/server.agent-contract-snapshot-endpoints.test.ts index 8fddcccc0b8d..7e300fe5aeee 100644 --- a/src/browser/server.agent-contract-snapshot-endpoints.test.ts +++ b/src/browser/server.agent-contract-snapshot-endpoints.test.ts @@ -64,14 +64,16 @@ describe("browser control server", () => { }); expect(nav.ok).toBe(true); expect(typeof nav.targetId).toBe("string"); - expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - url: "https://example.com", - ssrfPolicy: { - dangerouslyAllowPrivateNetwork: true, - }, - }); + expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + cdpUrl: state.cdpBaseUrl, + targetId: "abcd1234", + url: "https://example.com", + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, + }, + }), + ); const click = await postJson<{ ok: boolean }>(`${base}/act`, { kind: "click", diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index fe158fbb5f54..7edff76fe677 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -636,14 +636,6 @@ describe("update-cli", () => { } }); - it("updateCommand skips restart when --no-restart is set", async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); - - await updateCommand({ restart: false }); - - expect(runDaemonRestart).not.toHaveBeenCalled(); - }); - it("updateCommand skips success message when restart does not run", async () => { vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); vi.mocked(runDaemonRestart).mockResolvedValue(false); diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 19bb776b49ab..18474914681c 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -134,7 +134,7 @@ describe("config io write", () => { logger: silentLogger, }); - const invalidConfig = { + const invalidConfig: OpenClawConfig = { channels: { telegram: { dmPolicy: "open", diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 1f41edaa040e..d5da9b0a0b7e 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -105,12 +105,12 @@ describe("runCommandWithTimeout", () => { "clearInterval(ticker);", "process.exit(0);", "}", - "}, 40);", + "}, 12);", ].join(" "), ], { timeoutMs: 5_000, - noOutputTimeoutMs: 1_500, + noOutputTimeoutMs: 120, }, ); From ee423819517f06397cac1e5938003edf04c920af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:43:28 +0000 Subject: [PATCH 134/408] chore: add mailmap mappings for cherry-picked contributors --- .mailmap | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .mailmap diff --git a/.mailmap b/.mailmap new file mode 100644 index 000000000000..9190f88b6e08 --- /dev/null +++ b/.mailmap @@ -0,0 +1,13 @@ +# Canonical contributor identity mappings for cherry-picked commits. +bmendonca3 <208517100+bmendonca3@users.noreply.github.com> +hcl <7755017+hclsys@users.noreply.github.com> +Glucksberg <80581902+Glucksberg@users.noreply.github.com> +JackyWay <53031570+JackyWay@users.noreply.github.com> +Marcus Castro <7562095+mcaxtr@users.noreply.github.com> +Marc Gratch <2238658+mgratch@users.noreply.github.com> +Peter Machona <7957943+chilu18@users.noreply.github.com> +Ben Marvell <92585+easternbloc@users.noreply.github.com> +zerone0x <39543393+zerone0x@users.noreply.github.com> +Marco Di Dionisio <3519682+marcodd23@users.noreply.github.com> +mujiannan <46643837+mujiannan@users.noreply.github.com> +Santhanakrishnan <239082898+bitfoundry-ai@users.noreply.github.com> From 10cd4b5e6811f161e0a06762029b4a64356c8537 Mon Sep 17 00:00:00 2001 From: Arturo <34192856+afern247@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:44:11 +0000 Subject: [PATCH 135/408] chore: credit PR #24705 contributor attribution Attribution-only commit for the bot-authored upstream patch landed from #24705. From 91ea6ad8ec2e701d10c2c94684238ba2699a0769 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:46:19 +0000 Subject: [PATCH 136/408] docs(changelog): reorder unreleased fixes by user impact --- CHANGELOG.md | 73 ++++++++++++++++++++++++++-------------------------- 1 file changed, 36 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d53e20462f..376421b0833c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,59 +14,58 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. +- Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. +- Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads. +- Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. +- Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution. +- Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings. +- Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: restore two-phase approval registration + wait-decision handling for gateway/node exec paths, requiring approval IDs to be registered before returning `approval-pending` and honoring server-assigned approval IDs during wait resolution to prevent orphaned `/approve` flows and immediate-return races (`ask:on-miss`). This ships in the next npm release. Thanks @vitalyis for reporting. +- Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: recognize `busybox`/`toybox` shell applets in wrapper analysis and allow-always persistence, persist inner executables instead of multiplexer wrapper binaries, and fail closed when multiplexer unwrapping is unsafe to prevent allow-always bypasses. This ships in the next npm release. Thanks @jiseoung for reporting. +- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting. +- Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. +- Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. +- WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. +- Channels/Reasoning: suppress reasoning/thinking payload segments in the shared channel dispatch path so non-Telegram channels (including WhatsApp and Web) no longer emit internal reasoning blocks as user-visible replies. (#24991) Thanks @stakeswky. +- Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) +- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants) and accept trailing punctuation (for example `STOP OPENCLAW!!!`) so emergency stop messages are caught more reliably. +- WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. +- WhatsApp/Access control: honor `selfChatMode` in inbound access-control checks. (#24738) +- WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. +- Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. +- Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. - Gateway/Browser control: load `src/browser/server.js` during browser-control startup so the control listener starts reliably when browser control is enabled. (#23974) Thanks @ieaves. - Browser/Chrome relay: harden debugger detach handling during full-page navigation with bounded auto-reattach retries and better cancellation behavior for user/devtools detaches. (#19766) Thanks @nishantkabra77. - Browser/Chrome extension options: validate relay `/json/version` payload shape and content type (not just HTTP status) to detect wrong-port gateway checks, and clarify relay port derivation for custom gateway ports (`gateway + 3`). (#22252) Thanks @krizpoon. +- Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. - Onboarding/Custom providers: raise verification probe token budgets for OpenAI and Anthropic compatibility checks to avoid false negatives on strict provider defaults. (#24743) Thanks @Glucksberg. -- WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. +- Auth/OAuth: classify missing OAuth scopes as auth failures for clearer remediation and retry behavior. (#24761) - Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. -- Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. -- Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. -- Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. -- Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. -- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants) and accept trailing punctuation (for example `STOP OPENCLAW!!!`) so emergency stop messages are caught more reliably. -- Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) -- Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) - Sessions/Reasoning: persist `reasoningLevel: "off"` explicitly instead of deleting it so session overrides survive patch/update flows. (#24406, #24559) -- Plugins/Config: use plugin manifest `id` (instead of npm package name) for config entry keys so plugin settings stay bound correctly. (#24796) +- Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) - Synology Chat/Webhooks: deregister stale webhook routes before re-registering on channel restart to prevent duplicate route handling. (#24971) +- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. +- Plugins/Config: use plugin manifest `id` (instead of npm package name) for config entry keys so plugin settings stay bound correctly. (#24796) +- Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. - Gateway/Prompt builder: safely extract text from mixed content arrays when assembling prompts to avoid malformed prompt payloads. (#24946) -- WhatsApp/Access control: honor `selfChatMode` in inbound access-control checks. (#24738) -- Slack/Restart sentinel: map `threadId` to `replyToId` for restart sentinel notifications. (#24885) - Gateway/Slug generation: respect agent-level model config in slug generation flows. (#24776) - Agents/Workspace paths: strip null bytes and guard undefined `.trim()` calls for workspace-path handling to avoid `ENOTDIR`/`TypeError` crashes. (#24876, #24875) -- Auth/OAuth: classify missing OAuth scopes as auth failures for clearer remediation and retry behavior. (#24761) +- Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg. +- Sessions/Model overrides: keep stored sub-agent model overrides when `agents.defaults.models` is empty (allow-any mode) instead of resetting to defaults. (#21088) Thanks @Slats24. +- Subagents/Registry: prune orphaned restored runs (missing child session/sessionId) before retry/announce resume to prevent zombie entries and stale completion retries, and clarify status output to report bootstrap-file presence semantics. (#24244) Thanks @HeMuling. +- Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) - Doctor/UX: suppress the redundant "Run doctor --fix" hint when already in fix mode with no changes. (#24666) - Doctor/Nix: skip false-positive permission warnings for Nix store symlinks in state-integrity checks. (#24901) - Update/Systemd: back up an existing systemd unit before overwriting it during update flows. (#24350, #24937) - Install/Global detection: resolve symlinks when detecting pnpm/bun global install paths. (#24744) - Infra/Windows TOCTOU: handle Windows `dev=0` edge cases in same-file identity checks. (#24939) - Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) -- Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) -- Sessions/Model overrides: keep stored sub-agent model overrides when `agents.defaults.models` is empty (allow-any mode) instead of resetting to defaults. (#21088) Thanks @Slats24. -- Subagents/Registry: prune orphaned restored runs (missing child session/sessionId) before retry/announce resume to prevent zombie entries and stale completion retries, and clarify status output to report bootstrap-file presence semantics. (#24244) Thanks @HeMuling. -- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. -- Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg. -- WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. -- WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. -- Channels/Reasoning: suppress reasoning/thinking payload segments in the shared channel dispatch path so non-Telegram channels (including WhatsApp and Web) no longer emit internal reasoning blocks as user-visible replies. (#24991) Thanks @stakeswky. -- Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. -- Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. -- Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. -- Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. -- Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads. -- Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. -- Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution. -- Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings. -- Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec approvals: restore two-phase approval registration + wait-decision handling for gateway/node exec paths, requiring approval IDs to be registered before returning `approval-pending` and honoring server-assigned approval IDs during wait resolution to prevent orphaned `/approve` flows and immediate-return races (`ask:on-miss`). This ships in the next npm release. Thanks @vitalyis for reporting. -- Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec approvals: recognize `busybox`/`toybox` shell applets in wrapper analysis and allow-always persistence, persist inner executables instead of multiplexer wrapper binaries, and fail closed when multiplexer unwrapping is unsafe to prevent allow-always bypasses. This ships in the next npm release. Thanks @jiseoung for reporting. -- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting. ## 2026.2.23 (Unreleased) From fd10286819b3826659ebc14dc5063295b8036090 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:47:36 +0000 Subject: [PATCH 137/408] docs(changelog): mark allowFrom id-only default as breaking --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 376421b0833c..9d840c565caa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Breaking - **BREAKING:** non-loopback Control UI now requires explicit `gateway.controlUi.allowedOrigins` (full origins). Startup fails closed when missing unless `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` is set to use Host-header origin fallback mode. +- **BREAKING:** channel `allowFrom` matching is now ID-only by default across channels that previously allowed mutable name/tag/email principal matching. If you relied on direct mutable-name matching, migrate allowlists to stable IDs (recommended) or explicitly opt back in with `channels..dangerouslyAllowNameMatching=true` (break-glass compatibility mode). (#24907) ### Changes From 936f2449bd26ce0465d70f2a262c3855d8b48c95 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 05:02:40 +0000 Subject: [PATCH 138/408] chore(release): prep 2026.2.23-beta.1 changelog --- CHANGELOG.md | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d840c565caa..1362366f1dbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Changes - Subagents/Sessions: add `agents.defaults.subagents.runTimeoutSeconds` so `sessions_spawn` can inherit a configurable default timeout when the tool call omits `runTimeoutSeconds` (unset remains `0`, meaning no timeout). (#24594) Thanks @mitchmcalister. +- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. ### Fixes @@ -51,7 +52,6 @@ Docs: https://docs.openclaw.ai - Sessions/Reasoning: persist `reasoningLevel: "off"` explicitly instead of deleting it so session overrides survive patch/update flows. (#24406, #24559) - Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) - Synology Chat/Webhooks: deregister stale webhook routes before re-registering on channel restart to prevent duplicate route handling. (#24971) -- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - Plugins/Config: use plugin manifest `id` (instead of npm package name) for config entry keys so plugin settings stay bound correctly. (#24796) - Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. - Gateway/Prompt builder: safely extract text from mixed content arrays when assembling prompts to avoid malformed prompt payloads. (#24946) @@ -77,6 +77,10 @@ Docs: https://docs.openclaw.ai - Docs/Prompt caching: add a dedicated prompt-caching reference covering `cacheRetention`, per-agent `params` merge precedence, Bedrock/OpenRouter behavior, and cache-ttl + heartbeat tuning. Thanks @svenssonaxel. - Gateway/HTTP security headers: add optional `gateway.http.securityHeaders.strictTransportSecurity` support to emit `Strict-Transport-Security` for direct HTTPS deployments, with runtime wiring, validation, tests, and hardening docs. - Sessions/Cron: harden session maintenance with `openclaw sessions cleanup`, per-agent store targeting, disk-budget controls (`session.maintenance.maxDiskBytes` / `highWaterBytes`), and safer transcript/archive cleanup + run-log retention behavior. (#24753) thanks @gumadeiras. +- Tools/web_search: add `provider: "kimi"` (Moonshot) support with key/config schema wiring and a corrected two-step `$web_search` tool flow that echoes tool results before final synthesis, including citation extraction from search results. (#16616, #18822) Thanks @adshine. +- Media understanding/Video: add a native Moonshot video provider and include Moonshot in auto video key detection, plus refactor video execution to honor `entry/config/provider` baseUrl+header precedence (matching audio behavior). (#12063) Thanks @xiaoyaner0201. +- Agents/Config: support per-agent `params` overrides merged on top of model defaults (including `cacheRetention`) so mixed-traffic agents can tune cache behavior independently. (#17470, #17112) Thanks @rrenamed. +- Agents/Bootstrap: cache bootstrap file snapshots per session key and clear them on session reset/delete, reducing prompt-cache invalidations from in-session `AGENTS.md`/`MEMORY.md` writes. (#22220) Thanks @anisoptera. ### Breaking @@ -88,8 +92,6 @@ Docs: https://docs.openclaw.ai - Tests/Vitest: tier local parallel worker defaults by host memory, keep gateway serial by default on non-high-memory hosts, and document a low-profile fallback command for memory-constrained land/gate runs to prevent local OOMs. (#24719) Thanks @ngutman. - WhatsApp/Group policy: fix `groupAllowFrom` sender filtering when `groupPolicy: "allowlist"` is set without explicit `groups` — previously all group messages were blocked even for allowlisted senders. (#24670) - Agents/Context pruning: extend `cache-ttl` eligibility to Moonshot/Kimi and ZAI/GLM providers (including OpenRouter model refs), so `contextPruning.mode: "cache-ttl"` is no longer silently skipped for those sessions. (#24497) Thanks @lailoo. -- Tools/web_search: add `provider: "kimi"` (Moonshot) support with key/config schema wiring and a corrected two-step `$web_search` tool flow that echoes tool results before final synthesis, including citation extraction from search results. (#16616, #18822) Thanks @adshine. -- Media understanding/Video: add a native Moonshot video provider and include Moonshot in auto video key detection, plus refactor video execution to honor `entry/config/provider` baseUrl+header precedence (matching audio behavior). (#12063) Thanks @xiaoyaner0201. - Doctor/Memory: query gateway-side default-agent memory embedding readiness during `openclaw doctor` (instead of inferring from generic gateway health), and warn when the gateway memory probe is unavailable or not ready while keeping `openclaw configure` remediation guidance. (#22327) thanks @therk. - Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86. - Telegram/Reactions: soft-fail reaction action errors (policy/token/emoji/API), accept snake_case `message_id`, and fallback to inbound message-id context when explicit `messageId` is omitted so DM reactions stay stable without regeneration loops. (#20236, #21001) Thanks @PeterShanxin and @vincentkoc. @@ -101,8 +103,6 @@ Docs: https://docs.openclaw.ai - Agents/Compaction: pass `agentDir` into manual `/compact` command runs so compaction auth/profile resolution stays scoped to the active agent. (#24133) thanks @Glucksberg. - Agents/Compaction: pass model metadata through the embedded runtime so safeguard summarization can run when `ctx.model` is unavailable, avoiding repeated `"Summary unavailable due to context limits"` fallback summaries. (#3479) Thanks @battman21, @hanxiao and @vincentkoc. - Agents/Compaction: cancel safeguard compaction when summary generation cannot run (missing model/API key or summarization failure), preserving history instead of truncating to fallback `"Summary unavailable"` text. (#10711) Thanks @DukeDeSouth and @vincentkoc. -- Agents/Config: support per-agent `params` overrides merged on top of model defaults (including `cacheRetention`) so mixed-traffic agents can tune cache behavior independently. (#17470, #17112) Thanks @rrenamed. -- Agents/Bootstrap: cache bootstrap file snapshots per session key and clear them on session reset/delete, reducing prompt-cache invalidations from in-session `AGENTS.md`/`MEMORY.md` writes. (#22220) Thanks @anisoptera. - Agents/Tools: make `session_status` read transcript-derived usage mid-turn and tail-read session logs for cache-aware context reporting without full-log scans. (#22387) Thanks @1ucian. - Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic and @Glucksberg. - Agents/Overflow: add Chinese context-overflow pattern detection in `isContextOverflowError` so localized provider errors route through overflow recovery paths. (#22855) Thanks @Clawborn. diff --git a/package.json b/package.json index be8ec9577e1e..d1f6c8b5ec3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.2.23", + "version": "2026.2.23-beta.1", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", From cafa8226d7c9cc1d451620196e0a414a29dd5cf5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 05:14:02 +0000 Subject: [PATCH 139/408] docs(changelog): move stop-signal expansion to changes --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1362366f1dbf..676279e0a44e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Subagents/Sessions: add `agents.defaults.subagents.runTimeoutSeconds` so `sessions_spawn` can inherit a configurable default timeout when the tool call omits `runTimeoutSeconds` (unset remains `0`, meaning no timeout). (#24594) Thanks @mitchmcalister. - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. +- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants) and accept trailing punctuation (for example `STOP OPENCLAW!!!`) so emergency stop messages are caught more reliably. ### Fixes @@ -36,7 +37,6 @@ Docs: https://docs.openclaw.ai - WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. - Channels/Reasoning: suppress reasoning/thinking payload segments in the shared channel dispatch path so non-Telegram channels (including WhatsApp and Web) no longer emit internal reasoning blocks as user-visible replies. (#24991) Thanks @stakeswky. - Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) -- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants) and accept trailing punctuation (for example `STOP OPENCLAW!!!`) so emergency stop messages are caught more reliably. - WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. - WhatsApp/Access control: honor `selfChatMode` in inbound access-control checks. (#24738) - WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. From 8ea936cdda080b1c18fbc36b0ff549c174b87b20 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 05:21:55 +0000 Subject: [PATCH 140/408] docs: clarify prompt caching intro --- docs/reference/prompt-caching.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/reference/prompt-caching.md b/docs/reference/prompt-caching.md index 8233fd9de3aa..67561e4a21b4 100644 --- a/docs/reference/prompt-caching.md +++ b/docs/reference/prompt-caching.md @@ -9,6 +9,10 @@ read_when: # Prompt caching +Prompt caching means the model provider can reuse unchanged prompt prefixes (usually system/developer instructions and other stable context) across turns instead of re-processing them every time. The first matching request writes cache tokens (`cacheWrite`), and later matching requests can read them back (`cacheRead`). + +Why this matters: lower token cost, faster responses, and more predictable performance for long-running sessions. Without caching, repeated prompts pay the full prompt cost on every turn even when most input did not change. + This page covers all cache-related knobs that affect prompt reuse and token cost. For Anthropic pricing details, see: From b817600533129771ace2801d7c05901c7f850fb8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 05:36:38 +0000 Subject: [PATCH 141/408] chore(release): cut 2026.2.23 --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 676279e0a44e..7c2f881c03c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,7 @@ Docs: https://docs.openclaw.ai - Infra/Windows TOCTOU: handle Windows `dev=0` edge cases in same-file identity checks. (#24939) - Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) -## 2026.2.23 (Unreleased) +## 2026.2.23 ### Changes diff --git a/package.json b/package.json index d1f6c8b5ec3a..be8ec9577e1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.2.23-beta.1", + "version": "2026.2.23", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", From 4b316c33db6ca9868bc30fb632174a37ef500dc2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 01:07:25 -0500 Subject: [PATCH 142/408] Auto-reply: normalize stop matching and add multilingual triggers (#25103) * Auto-reply tests: cover multilingual abort triggers * Auto-reply: normalize multilingual abort triggers * Gateway: route chat stop matching through abort parser * Gateway tests: cover chat stop parsing variants * Auto-reply tests: cover Russian and German stop words * Auto-reply: add Russian and German abort triggers * Gateway tests: include Russian and German stop forms * Telegram tests: route Russian and German stop forms to control lane * Changelog: note multilingual abort stop coverage * Changelog: add shared credit for abort shortcut update --- CHANGELOG.md | 2 +- src/auto-reply/reply/abort.test.ts | 25 ++++++++++++++++++++ src/auto-reply/reply/abort.ts | 21 ++++++++++++++++ src/gateway/chat-abort.test.ts | 17 +++++++++++++ src/gateway/chat-abort.ts | 8 ++----- src/telegram/bot.create-telegram-bot.test.ts | 10 ++++++++ 6 files changed, 76 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c2f881c03c8..4807eba7c7ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Docs: https://docs.openclaw.ai - Subagents/Sessions: add `agents.defaults.subagents.runTimeoutSeconds` so `sessions_spawn` can inherit a configurable default timeout when the tool call omits `runTimeoutSeconds` (unset remains `0`, meaning no timeout). (#24594) Thanks @mitchmcalister. - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. -- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants) and accept trailing punctuation (for example `STOP OPENCLAW!!!`) so emergency stop messages are caught more reliably. +- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), and add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms) so emergency stop messages are caught more reliably. Thanks @steipete and @vincentkoc. ### Fixes diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index b36855eb80c1..b35937a6003e 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -147,6 +147,25 @@ describe("abort detection", () => { "STOP OPENCLAW", "stop openclaw!!!", "stop don’t do anything", + "detente", + "detén", + "arrête", + "停止", + "やめて", + "止めて", + "रुको", + "توقف", + "стоп", + "остановись", + "останови", + "остановить", + "прекрати", + "halt", + "anhalten", + "aufhören", + "hoer auf", + "stopp", + "pare", ]; for (const candidate of positives) { expect(isAbortTrigger(candidate)).toBe(true); @@ -164,6 +183,12 @@ describe("abort detection", () => { expect(isAbortRequestText("stop")).toBe(true); expect(isAbortRequestText("stop action")).toBe(true); expect(isAbortRequestText("stop openclaw!!!")).toBe(true); + expect(isAbortRequestText("やめて")).toBe(true); + expect(isAbortRequestText("остановись")).toBe(true); + expect(isAbortRequestText("halt")).toBe(true); + expect(isAbortRequestText("stopp")).toBe(true); + expect(isAbortRequestText("pare")).toBe(true); + expect(isAbortRequestText(" توقف ")).toBe(true); expect(isAbortRequestText("/stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true); expect(isAbortRequestText("/status")).toBe(false); diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index 38bf576a435c..1f3572464e82 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -30,6 +30,27 @@ const ABORT_TRIGGERS = new Set([ "wait", "exit", "interrupt", + "detente", + "deten", + "detén", + "arrete", + "arrête", + "停止", + "やめて", + "止めて", + "रुको", + "توقف", + "стоп", + "остановись", + "останови", + "остановить", + "прекрати", + "halt", + "anhalten", + "aufhören", + "hoer auf", + "stopp", + "pare", "stop openclaw", "openclaw stop", "stop action", diff --git a/src/gateway/chat-abort.test.ts b/src/gateway/chat-abort.test.ts index 9829f45c9996..b008d7cc5918 100644 --- a/src/gateway/chat-abort.test.ts +++ b/src/gateway/chat-abort.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { abortChatRunById, + isChatStopCommandText, type ChatAbortOps, type ChatAbortControllerEntry, } from "./chat-abort.js"; @@ -42,6 +43,22 @@ function createOps(params: { }; } +describe("isChatStopCommandText", () => { + it("matches slash and standalone multilingual stop forms", () => { + expect(isChatStopCommandText(" /STOP!!! ")).toBe(true); + expect(isChatStopCommandText("stop please")).toBe(true); + expect(isChatStopCommandText("停止")).toBe(true); + expect(isChatStopCommandText("やめて")).toBe(true); + expect(isChatStopCommandText("توقف")).toBe(true); + expect(isChatStopCommandText("остановись")).toBe(true); + expect(isChatStopCommandText("halt")).toBe(true); + expect(isChatStopCommandText("stopp")).toBe(true); + expect(isChatStopCommandText("pare")).toBe(true); + expect(isChatStopCommandText("/status")).toBe(false); + expect(isChatStopCommandText("keep going")).toBe(false); + }); +}); + describe("abortChatRunById", () => { it("broadcasts aborted payload with partial message when buffered text exists", () => { const runId = "run-1"; diff --git a/src/gateway/chat-abort.ts b/src/gateway/chat-abort.ts index 0d544324133f..0210f9223f77 100644 --- a/src/gateway/chat-abort.ts +++ b/src/gateway/chat-abort.ts @@ -1,4 +1,4 @@ -import { isAbortTrigger } from "../auto-reply/reply/abort.js"; +import { isAbortRequestText } from "../auto-reply/reply/abort.js"; export type ChatAbortControllerEntry = { controller: AbortController; @@ -9,11 +9,7 @@ export type ChatAbortControllerEntry = { }; export function isChatStopCommandText(text: string): boolean { - const trimmed = text.trim(); - if (!trimmed) { - return false; - } - return trimmed.toLowerCase() === "/stop" || isAbortTrigger(trimmed); + return isAbortRequestText(text); } export function resolveChatRunExpiresAtMs(params: { diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index f5c4735ea758..816cf224dd3c 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -184,6 +184,16 @@ describe("createTelegramBot", () => { message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop please" }), }), ).toBe("telegram:123:control"); + expect( + getTelegramSequentialKey({ + message: mockMessage({ chat: mockChat({ id: 123 }), text: "остановись" }), + }), + ).toBe("telegram:123:control"); + expect( + getTelegramSequentialKey({ + message: mockMessage({ chat: mockChat({ id: 123 }), text: "halt" }), + }), + ).toBe("telegram:123:control"); expect( getTelegramSequentialKey({ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort" }), From 1c228dc249c7034145029341f7f500c3be260401 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:50:30 -0600 Subject: [PATCH 143/408] docs: add Val Alexander to maintainers list (#25197) * docs: add Val Alexander to maintainers list - Focus: UI/UX, Docs, and Agent DevX - GitHub: @BunsDev - X/Twitter: @BunsDev * Update CONTRIBUTING.md * fix: format --- CONTRIBUTING.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10d4f2907045..1386bc4881ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,9 @@ Welcome to the lobster tank! 🦞 - **Vincent Koc** - Agents, Telemetry, Hooks, Security - GitHub: [@vincentkoc](https://github.com/vincentkoc) · X: [@vincent_koc](https://x.com/vincent_koc) +- **Val Alexander** - UI/UX, Docs, and Agent DevX + - GitHub: [@BunsDev](https://github.com/BunsDev) · X: [@BunsDev](https://x.com/BunsDev) + - **Seb Slight** - Docs, Agent Reliability, Runtime Hardening - GitHub: [@sebslight](https://github.com/sebslight) · X: [@sebslig](https://x.com/sebslig) From 097a6a83a0184e0977ec6836177f930a01305337 Mon Sep 17 00:00:00 2001 From: Peter Machona Date: Tue, 24 Feb 2026 09:19:59 +0000 Subject: [PATCH 144/408] fix(cli): replace stale doctor/restart command hints (#24485) * fix(cli): replace stale doctor and restart hints * fix: add changelog for CLI hint updates (#24485) (thanks @chilu18) --------- Co-authored-by: Muhammed Mukhthar CM --- CHANGELOG.md | 1 + src/cli/daemon-cli/lifecycle.test.ts | 1 + src/cli/daemon-cli/lifecycle.ts | 2 +- src/cli/update-cli/update-command.ts | 2 +- src/commands/doctor-memory-search.test.ts | 21 ++++++++++++++++++--- src/commands/doctor-memory-search.ts | 4 ++-- 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4807eba7c7ad..4af2feb0b74f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Subagents/Registry: prune orphaned restored runs (missing child session/sessionId) before retry/announce resume to prevent zombie entries and stale completion retries, and clarify status output to report bootstrap-file presence semantics. (#24244) Thanks @HeMuling. - Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) - Doctor/UX: suppress the redundant "Run doctor --fix" hint when already in fix mode with no changes. (#24666) +- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. - Doctor/Nix: skip false-positive permission warnings for Nix store symlinks in state-integrity checks. (#24901) - Update/Systemd: back up an existing systemd unit before overwriting it during update flows. (#24350, #24937) - Install/Global detection: resolve symlinks when detecting pnpm/bun global install paths. (#24744) diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 741473f69c4b..41f7da868a32 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -126,6 +126,7 @@ describe("runDaemonRestart health checks", () => { await expect(runDaemonRestart({ json: true })).rejects.toMatchObject({ message: "Gateway restart timed out after 60s waiting for health checks.", + hints: ["openclaw gateway status --deep", "openclaw doctor"], }); expect(terminateStaleGatewayPids).not.toHaveBeenCalled(); expect(renderRestartDiagnostics).toHaveBeenCalledTimes(1); diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 413320289457..f6d230f0bb82 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -135,7 +135,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi } fail(`Gateway restart timed out after ${restartWaitSeconds}s waiting for health checks.`, [ - formatCliCommand("openclaw gateway status --probe --deep"), + formatCliCommand("openclaw gateway status --deep"), formatCliCommand("openclaw doctor"), ]); }, diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 3c672a02d5e1..1cce6c66e8e7 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -589,7 +589,7 @@ async function maybeRestartService(params: { } defaultRuntime.log( theme.muted( - `Run \`${replaceCliName(formatCliCommand("openclaw gateway status --probe --deep"), CLI_NAME)}\` for details.`, + `Run \`${replaceCliName(formatCliCommand("openclaw gateway status --deep"), CLI_NAME)}\` for details.`, ), ); } diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index a275fa600983..1c5c7a74d2dc 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -143,7 +143,7 @@ describe("noteMemorySearchHealth", () => { expect(message).toContain("reports memory embeddings are ready"); }); - it("uses configure hint when gateway probe is unavailable and API key is missing", async () => { + it("uses model configure hint when gateway probe is unavailable and API key is missing", async () => { resolveMemorySearchConfig.mockReturnValue({ provider: "gemini", local: {}, @@ -160,8 +160,23 @@ describe("noteMemorySearchHealth", () => { const message = note.mock.calls[0]?.[0] as string; expect(message).toContain("Gateway memory probe for default agent is not ready"); - expect(message).toContain("openclaw configure"); - expect(message).not.toContain("auth add"); + expect(message).toContain("openclaw configure --section model"); + expect(message).not.toContain("openclaw auth add --provider"); + }); + + it("uses model configure hint in auto mode when no provider credentials are found", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "auto", + local: {}, + remote: {}, + }); + + await noteMemorySearchHealth(cfg); + + expect(note).toHaveBeenCalledTimes(1); + const message = String(note.mock.calls[0]?.[0] ?? ""); + expect(message).toContain("openclaw configure --section model"); + expect(message).not.toContain("openclaw auth add --provider"); }); }); diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 5b5d39dd56fa..aebaef40229b 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -84,7 +84,7 @@ export async function noteMemorySearchHealth( "", "Fix (pick one):", `- Set ${envVar} in your environment`, - `- Configure credentials: ${formatCliCommand("openclaw configure")}`, + `- Configure credentials: ${formatCliCommand("openclaw configure --section model")}`, `- To disable: ${formatCliCommand("openclaw config set agents.defaults.memorySearch.enabled false")}`, "", `Verify: ${formatCliCommand("openclaw memory status --deep")}`, @@ -125,7 +125,7 @@ export async function noteMemorySearchHealth( "", "Fix (pick one):", "- Set OPENAI_API_KEY, GEMINI_API_KEY, VOYAGE_API_KEY, or MISTRAL_API_KEY in your environment", - `- Configure credentials: ${formatCliCommand("openclaw configure")}`, + `- Configure credentials: ${formatCliCommand("openclaw configure --section model")}`, `- For local embeddings: configure agents.defaults.memorySearch.provider and local model path`, `- To disable: ${formatCliCommand("openclaw config set agents.defaults.memorySearch.enabled false")}`, "", From 66e61ca6ce4442d721f11dbd35d4f527ceb775f0 Mon Sep 17 00:00:00 2001 From: LawrenceLuo <2507073658@qq.com> Date: Tue, 24 Feb 2026 21:27:23 +0900 Subject: [PATCH 145/408] docs: fix broken links in README (#25368) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /start/faq → /help/faq - /concepts/groups → /channels/groups - /concepts/group-messages → /channels/group-messages - /concepts/channel-routing → /channels/channel-routing Co-authored-by: LawrenceLuo <5390633+PinoHouse@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7387372192f9..1dcad2b7e125 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ It answers you on the channels you already use (WhatsApp, Telegram, Slack, Disco If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. -[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/start/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd) +[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd) Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal. The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**. @@ -145,13 +145,13 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies. - [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui). - [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [wizard](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor). - [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming. -- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/concepts/groups). +- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/channels/groups). - [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio). ### Channels - [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat). -- [Group routing](https://docs.openclaw.ai/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels). +- [Group routing](https://docs.openclaw.ai/channels/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels). ### Apps + nodes @@ -170,7 +170,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies. ### Runtime + safety -- [Channel routing](https://docs.openclaw.ai/concepts/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming). +- [Channel routing](https://docs.openclaw.ai/channels/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming). - [Presence](https://docs.openclaw.ai/concepts/presence), [typing indicators](https://docs.openclaw.ai/concepts/typing-indicators), and [usage tracking](https://docs.openclaw.ai/concepts/usage-tracking). - [Models](https://docs.openclaw.ai/concepts/models), [model failover](https://docs.openclaw.ai/concepts/model-failover), and [session pruning](https://docs.openclaw.ai/concepts/session-pruning). - [Security](https://docs.openclaw.ai/gateway/security) and [troubleshooting](https://docs.openclaw.ai/channels/troubleshooting). From 649d141527488281e75d9f67e380ee522426817b Mon Sep 17 00:00:00 2001 From: Mariana Sinisterra Date: Tue, 24 Feb 2026 07:56:08 -0500 Subject: [PATCH 146/408] fix(ui): prevent tabnabbing in chat images (#18685) * UI: prevent tabnabbing in chat images * ui: remove comment from image open helper --------- Co-authored-by: Shakker --- ui/src/ui/chat/grouped-render.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 7c36713c3c0f..4726596c6e12 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -200,6 +200,13 @@ function renderMessageImages(images: ImageBlock[]) { return nothing; } + const openImage = (url: string) => { + const opened = window.open(url, "_blank", "noopener,noreferrer"); + if (opened) { + opened.opener = null; + } + }; + return html`
${images.map( @@ -208,7 +215,7 @@ function renderMessageImages(images: ImageBlock[]) { src=${img.url} alt=${img.alt ?? "Attached image"} class="chat-message-image" - @click=${() => window.open(img.url, "_blank")} + @click=${() => openImage(img.url)} /> `, )} From aceb17a30e6cf94eaa49f4de9389fe316df25b4b Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 24 Feb 2026 13:04:10 +0000 Subject: [PATCH 147/408] changelog: add entry for PR 18685 fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4af2feb0b74f..368a6561482e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. - Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads. From 2bad30b4d3b300b314f9968544db9396e06888e9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 13:42:36 +0000 Subject: [PATCH 148/408] chore(release): bump version to 2026.2.24 --- CHANGELOG.md | 2 +- apps/android/app/build.gradle.kts | 2 +- apps/ios/Sources/Info.plist | 2 +- apps/ios/Tests/Info.plist | 2 +- apps/macos/Sources/OpenClaw/Resources/Info.plist | 2 +- docs/platforms/mac/release.md | 14 +++++++------- package.json | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 368a6561482e..8d61997f5c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ Docs: https://docs.openclaw.ai -## Unreleased +## 2026.2.24 (Unreleased) ### Breaking diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 52e1014e7ba7..ad3718b1138c 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -22,7 +22,7 @@ android { minSdk = 31 targetSdk = 36 versionCode = 202602230 - versionName = "2026.2.23" + versionName = "2026.2.24" 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/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index c34fccb5052d..28633cc370b1 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.23 + 2026.2.24 CFBundleURLTypes diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index a3420e273216..2dca88f97f1a 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.2.23 + 2026.2.24 CFBundleVersion 20260223 diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index 3a425368d090..02928da0eb8f 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.2.23 + 2026.2.24 CFBundleVersion 202602230 CFBundleIconFile diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 029ab3eed934..61180e77aab0 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -34,17 +34,17 @@ Notes: # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.23 \ +APP_VERSION=2026.2.24 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-app.sh # Zip for distribution (includes resource forks for Sparkle delta support) -ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.23.zip +ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.24.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.23.dmg +scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.24.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: @@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.23.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.23 \ +APP_VERSION=2026.2.24 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh # Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.23.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.24.dSYM.zip ``` ## Appcast entry @@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl Use the release note generator so Sparkle renders formatted HTML notes: ```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.23.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml +SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.24.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml ``` Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. @@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when ## Publish & verify -- Upload `OpenClaw-2026.2.23.zip` (and `OpenClaw-2026.2.23.dSYM.zip`) to the GitHub release for tag `v2026.2.23`. +- Upload `OpenClaw-2026.2.24.zip` (and `OpenClaw-2026.2.24.dSYM.zip`) to the GitHub release for tag `v2026.2.24`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`. - Sanity checks: - `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200. diff --git a/package.json b/package.json index be8ec9577e1e..c1b68821083d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.2.23", + "version": "2026.2.24", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", From 96b21f48234b30c4f770acc1fac9a4709ab43256 Mon Sep 17 00:00:00 2001 From: yingchunbai <33477283+yingchunbai@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:26:20 +0800 Subject: [PATCH 149/408] fix(macos): remove self-delegate on cost usage submenu to prevent recursive dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cost usage submenu set `menu.delegate = self` (the MenuSessionsInjector), which caused `menuWillOpen(_:)` to call `inject(into:)` on the submenu when it opened. This re-inserted the "Usage cost (30 days)" item into the submenu, creating an infinite recursive dropdown. Fix: remove the delegate assignment from the submenu — it does not need the injector's delegate behavior since it only contains a static chart view. Closes #25167 Co-Authored-By: Claude Opus 4.6 --- apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift index 37fd6ca25052..fde2d0e0bdbd 100644 --- a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift +++ b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift @@ -493,7 +493,6 @@ extension MenuSessionsInjector { guard !summary.daily.isEmpty else { return nil } let menu = NSMenu() - menu.delegate = self let chartView = CostUsageHistoryMenuView(summary: summary, width: width) let hosting = NSHostingView(rootView: AnyView(chartView)) From 7c99a733a97e4115ff9e18adb07b74165dfc4521 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 13:48:37 +0000 Subject: [PATCH 150/408] fix: harden macOS usage cost submenu recursion guard (#25341) (thanks @yingchunbai) --- CHANGELOG.md | 1 + .../OpenClaw/MenuSessionsInjector.swift | 8 ++++ .../MenuSessionsInjectorTests.swift | 41 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d61997f5c8e..3585db45b995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. - Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift index fde2d0e0bdbd..eb6271d0a8ce 100644 --- a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift +++ b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift @@ -446,6 +446,8 @@ extension MenuSessionsInjector { private func buildUsageOverflowMenu(rows: [UsageRow], width: CGFloat) -> NSMenu { let menu = NSMenu() + // Keep submenu delegate nil: reusing the status-menu delegate here causes + // recursive reinjection whenever this submenu is opened. for row in rows { let item = NSMenuItem() item.tag = self.tag @@ -1225,6 +1227,12 @@ extension MenuSessionsInjector { self.usageCacheUpdatedAt = Date() } + func setTestingCostUsageSummary(_ summary: GatewayCostUsageSummary?, errorText: String? = nil) { + self.cachedCostSummary = summary + self.cachedCostErrorText = errorText + self.costCacheUpdatedAt = Date() + } + func injectForTesting(into menu: NSMenu) { self.inject(into: menu) } diff --git a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift index 8395ed145ce8..ff63673b9e08 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift @@ -93,4 +93,45 @@ struct MenuSessionsInjectorTests { #expect(menu.items.contains { $0.tag == 9_415_557 }) #expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem }) } + + @Test func costUsageSubmenuDoesNotUseInjectorDelegate() { + let injector = MenuSessionsInjector() + injector.setTestingControlChannelConnected(true) + + let summary = GatewayCostUsageSummary( + updatedAt: Date().timeIntervalSince1970 * 1000, + days: 1, + daily: [ + GatewayCostUsageDay( + date: "2026-02-24", + input: 10, + output: 20, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 30, + totalCost: 0.12, + missingCostEntries: 0), + ], + totals: GatewayCostUsageTotals( + input: 10, + output: 20, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 30, + totalCost: 0.12, + missingCostEntries: 0)) + injector.setTestingCostUsageSummary(summary, errorText: nil) + + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) + + injector.injectForTesting(into: menu) + + let usageCostItem = menu.items.first { $0.title == "Usage cost (30 days)" } + #expect(usageCostItem != nil) + #expect(usageCostItem?.submenu != nil) + #expect(usageCostItem?.submenu?.delegate == nil) + } } From e3ac491da35372d5ce1a14fb5b770c82ca32d778 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 13:51:38 +0000 Subject: [PATCH 151/408] docs(changelog): trim 2026.2.24 unreleased entries --- CHANGELOG.md | 59 +--------------------------------------------------- 1 file changed, 1 insertion(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3585db45b995..ce418f75452b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,72 +4,15 @@ Docs: https://docs.openclaw.ai ## 2026.2.24 (Unreleased) -### Breaking - -- **BREAKING:** non-loopback Control UI now requires explicit `gateway.controlUi.allowedOrigins` (full origins). Startup fails closed when missing unless `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` is set to use Host-header origin fallback mode. -- **BREAKING:** channel `allowFrom` matching is now ID-only by default across channels that previously allowed mutable name/tag/email principal matching. If you relied on direct mutable-name matching, migrate allowlists to stable IDs (recommended) or explicitly opt back in with `channels..dangerouslyAllowNameMatching=true` (break-glass compatibility mode). (#24907) - ### Changes -- Subagents/Sessions: add `agents.defaults.subagents.runTimeoutSeconds` so `sessions_spawn` can inherit a configurable default timeout when the tool call omits `runTimeoutSeconds` (unset remains `0`, meaning no timeout). (#24594) Thanks @mitchmcalister. -- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. -- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), and add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms) so emergency stop messages are caught more reliably. Thanks @steipete and @vincentkoc. +- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), and add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms) so emergency stop messages are caught more reliably. (#25103) Thanks @steipete and @vincentkoc. ### Fixes - macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. - Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase. -- Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. -- Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. -- Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads. -- Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. -- Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution. -- Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings. -- Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec approvals: restore two-phase approval registration + wait-decision handling for gateway/node exec paths, requiring approval IDs to be registered before returning `approval-pending` and honoring server-assigned approval IDs during wait resolution to prevent orphaned `/approve` flows and immediate-return races (`ask:on-miss`). This ships in the next npm release. Thanks @vitalyis for reporting. -- Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec approvals: recognize `busybox`/`toybox` shell applets in wrapper analysis and allow-always persistence, persist inner executables instead of multiplexer wrapper binaries, and fail closed when multiplexer unwrapping is unsafe to prevent allow-always bypasses. This ships in the next npm release. Thanks @jiseoung for reporting. -- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting. -- Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. -- Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. -- WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. -- Channels/Reasoning: suppress reasoning/thinking payload segments in the shared channel dispatch path so non-Telegram channels (including WhatsApp and Web) no longer emit internal reasoning blocks as user-visible replies. (#24991) Thanks @stakeswky. -- Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) -- WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. -- WhatsApp/Access control: honor `selfChatMode` in inbound access-control checks. (#24738) -- WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. -- Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. -- Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. -- Gateway/Browser control: load `src/browser/server.js` during browser-control startup so the control listener starts reliably when browser control is enabled. (#23974) Thanks @ieaves. -- Browser/Chrome relay: harden debugger detach handling during full-page navigation with bounded auto-reattach retries and better cancellation behavior for user/devtools detaches. (#19766) Thanks @nishantkabra77. -- Browser/Chrome extension options: validate relay `/json/version` payload shape and content type (not just HTTP status) to detect wrong-port gateway checks, and clarify relay port derivation for custom gateway ports (`gateway + 3`). (#22252) Thanks @krizpoon. -- Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. -- Onboarding/Custom providers: raise verification probe token budgets for OpenAI and Anthropic compatibility checks to avoid false negatives on strict provider defaults. (#24743) Thanks @Glucksberg. -- Auth/OAuth: classify missing OAuth scopes as auth failures for clearer remediation and retry behavior. (#24761) -- Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. -- Sessions/Reasoning: persist `reasoningLevel: "off"` explicitly instead of deleting it so session overrides survive patch/update flows. (#24406, #24559) -- Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) -- Synology Chat/Webhooks: deregister stale webhook routes before re-registering on channel restart to prevent duplicate route handling. (#24971) -- Plugins/Config: use plugin manifest `id` (instead of npm package name) for config entry keys so plugin settings stay bound correctly. (#24796) -- Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. -- Gateway/Prompt builder: safely extract text from mixed content arrays when assembling prompts to avoid malformed prompt payloads. (#24946) -- Gateway/Slug generation: respect agent-level model config in slug generation flows. (#24776) -- Agents/Workspace paths: strip null bytes and guard undefined `.trim()` calls for workspace-path handling to avoid `ENOTDIR`/`TypeError` crashes. (#24876, #24875) -- Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg. -- Sessions/Model overrides: keep stored sub-agent model overrides when `agents.defaults.models` is empty (allow-any mode) instead of resetting to defaults. (#21088) Thanks @Slats24. -- Subagents/Registry: prune orphaned restored runs (missing child session/sessionId) before retry/announce resume to prevent zombie entries and stale completion retries, and clarify status output to report bootstrap-file presence semantics. (#24244) Thanks @HeMuling. -- Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) -- Doctor/UX: suppress the redundant "Run doctor --fix" hint when already in fix mode with no changes. (#24666) - CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. -- Doctor/Nix: skip false-positive permission warnings for Nix store symlinks in state-integrity checks. (#24901) -- Update/Systemd: back up an existing systemd unit before overwriting it during update flows. (#24350, #24937) -- Install/Global detection: resolve symlinks when detecting pnpm/bun global install paths. (#24744) -- Infra/Windows TOCTOU: handle Windows `dev=0` edge cases in same-file identity checks. (#24939) -- Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) ## 2026.2.23 From 32d7756d8c21abdea4700ec9064bae860e67b5c2 Mon Sep 17 00:00:00 2001 From: DoncicX <348031375@qq.com> Date: Tue, 24 Feb 2026 13:40:35 +0800 Subject: [PATCH 152/408] iOS: extract device/platform info into DeviceInfoHelper, keep Settings platform string as iOS X.Y.Z --- .../ios/Sources/Device/DeviceInfoHelper.swift | 71 +++++++++++++++++++ .../Sources/Device/DeviceStatusService.swift | 17 ++--- .../Gateway/GatewayConnectionController.swift | 46 ++---------- apps/ios/Sources/Settings/SettingsTab.swift | 32 +-------- apps/ios/SwiftSources.input.xcfilelist | 3 + 5 files changed, 87 insertions(+), 82 deletions(-) create mode 100644 apps/ios/Sources/Device/DeviceInfoHelper.swift diff --git a/apps/ios/Sources/Device/DeviceInfoHelper.swift b/apps/ios/Sources/Device/DeviceInfoHelper.swift new file mode 100644 index 000000000000..eeed54c46526 --- /dev/null +++ b/apps/ios/Sources/Device/DeviceInfoHelper.swift @@ -0,0 +1,71 @@ +import Foundation +import UIKit + +import Darwin + +/// Shared device and platform info for Settings, gateway node payloads, and device status. +enum DeviceInfoHelper { + /// e.g. "iOS 18.0.0" or "iPadOS 18.0.0" by interface idiom. Use for gateway/device payloads. + static func platformString() -> String { + let v = ProcessInfo.processInfo.operatingSystemVersion + let name = switch UIDevice.current.userInterfaceIdiom { + case .pad: + "iPadOS" + case .phone: + "iOS" + default: + "iOS" + } + return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + } + + /// Always "iOS X.Y.Z" for UI display (e.g. Settings), matching legacy behavior on iPad. + static func platformStringForDisplay() -> String { + let v = ProcessInfo.processInfo.operatingSystemVersion + return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + } + + /// Device family for display: "iPad", "iPhone", or "iOS". + static func deviceFamily() -> String { + switch UIDevice.current.userInterfaceIdiom { + case .pad: + "iPad" + case .phone: + "iPhone" + default: + "iOS" + } + } + + /// Machine model identifier from uname (e.g. "iPhone17,1"). + static func modelIdentifier() -> String { + var systemInfo = utsname() + uname(&systemInfo) + let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in + String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) + } + let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? "unknown" : trimmed + } + + /// App marketing version only, e.g. "2026.2.0" or "dev". + static func appVersion() -> String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" + } + + /// App build string, e.g. "123" or "". + static func appBuild() -> String { + let raw = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" + return raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Display string for Settings: "1.2.3" or "1.2.3 (456)" when build differs. + static func openClawVersionString() -> String { + let version = appVersion() + let build = appBuild() + if build.isEmpty || build == version { + return version + } + return "\(version) (\(build))" + } +} diff --git a/apps/ios/Sources/Device/DeviceStatusService.swift b/apps/ios/Sources/Device/DeviceStatusService.swift index fed2716b5b8a..a80a98101fae 100644 --- a/apps/ios/Sources/Device/DeviceStatusService.swift +++ b/apps/ios/Sources/Device/DeviceStatusService.swift @@ -26,12 +26,12 @@ final class DeviceStatusService: DeviceStatusServicing { func info() -> OpenClawDeviceInfoPayload { let device = UIDevice.current - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0" + let appVersion = DeviceInfoHelper.appVersion() + let appBuild = DeviceStatusService.fallbackAppBuild(DeviceInfoHelper.appBuild()) let locale = Locale.preferredLanguages.first ?? Locale.current.identifier return OpenClawDeviceInfoPayload( deviceName: device.name, - modelIdentifier: Self.modelIdentifier(), + modelIdentifier: DeviceInfoHelper.modelIdentifier(), systemName: device.systemName, systemVersion: device.systemVersion, appVersion: appVersion, @@ -75,13 +75,8 @@ final class DeviceStatusService: DeviceStatusServicing { return OpenClawStorageStatusPayload(totalBytes: total, freeBytes: free, usedBytes: used) } - private static func modelIdentifier() -> String { - var systemInfo = utsname() - uname(&systemInfo) - let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in - String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) - } - let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? "unknown" : trimmed + /// Fallback for payloads that require a non-empty build (e.g. "0"). + private static func fallbackAppBuild(_ build: String) -> String { + build.isEmpty ? "0" : build } } diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 2b7f94ba4532..a770fcb2c6f8 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -921,44 +921,6 @@ final class GatewayConnectionController { private static func motionAvailable() -> Bool { CMMotionActivityManager.isActivityAvailable() || CMPedometer.isStepCountingAvailable() } - - private func platformString() -> String { - let v = ProcessInfo.processInfo.operatingSystemVersion - let name = switch UIDevice.current.userInterfaceIdiom { - case .pad: - "iPadOS" - case .phone: - "iOS" - default: - "iOS" - } - return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" - } - - private func deviceFamily() -> String { - switch UIDevice.current.userInterfaceIdiom { - case .pad: - "iPad" - case .phone: - "iPhone" - default: - "iOS" - } - } - - private func modelIdentifier() -> String { - var systemInfo = utsname() - uname(&systemInfo) - let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in - String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) - } - let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? "unknown" : trimmed - } - - private func appVersion() -> String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - } } #if DEBUG @@ -980,19 +942,19 @@ extension GatewayConnectionController { } func _test_platformString() -> String { - self.platformString() + DeviceInfoHelper.platformString() } func _test_deviceFamily() -> String { - self.deviceFamily() + DeviceInfoHelper.deviceFamily() } func _test_modelIdentifier() -> String { - self.modelIdentifier() + DeviceInfoHelper.modelIdentifier() } func _test_appVersion() -> String { - self.appVersion() + DeviceInfoHelper.appVersion() } func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) { diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 024a4cbf42b1..3ff2ed465c31 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -374,9 +374,9 @@ struct SettingsTab: View { .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) - LabeledContent("Device", value: self.deviceFamily()) - LabeledContent("Platform", value: self.platformString()) - LabeledContent("OpenClaw", value: self.openClawVersionString()) + LabeledContent("Device", value: DeviceInfoHelper.deviceFamily()) + LabeledContent("Platform", value: DeviceInfoHelper.platformStringForDisplay()) + LabeledContent("OpenClaw", value: DeviceInfoHelper.openClawVersionString()) } } } @@ -584,32 +584,6 @@ struct SettingsTab: View { return trimmed.isEmpty ? "Not connected" : trimmed } - private func platformString() -> String { - let v = ProcessInfo.processInfo.operatingSystemVersion - return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" - } - - private func deviceFamily() -> String { - switch UIDevice.current.userInterfaceIdiom { - case .pad: - "iPad" - case .phone: - "iPhone" - default: - "iOS" - } - } - - private func openClawVersionString() -> String { - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" - let trimmedBuild = build.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedBuild.isEmpty || trimmedBuild == version { - return version - } - return "\(version) (\(trimmedBuild))" - } - private func featureToggle( _ title: String, isOn: Binding, diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 5b1ba7d70e63..514ca7326736 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -4,6 +4,9 @@ Sources/Gateway/GatewayDiscoveryModel.swift Sources/Gateway/GatewaySettingsStore.swift Sources/Gateway/KeychainStore.swift Sources/Camera/CameraController.swift +Sources/Device/DeviceInfoHelper.swift +Sources/Device/DeviceStatusService.swift +Sources/Device/NetworkStatusService.swift Sources/Chat/ChatSheet.swift Sources/Chat/IOSGatewayChatTransport.swift Sources/OpenClawApp.swift From 4d124e4a9b755d012eb81c9a44746eaaedacc9c1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:03:04 +0000 Subject: [PATCH 153/408] feat(security): warn on likely multi-user trust-model mismatch --- CHANGELOG.md | 1 + docs/cli/security.md | 2 + docs/gateway/security/index.md | 16 +++ src/security/audit-extra.sync.ts | 215 ++++++++++++++++++++++++------- src/security/audit-extra.ts | 1 + src/security/audit.test.ts | 47 +++++++ src/security/audit.ts | 2 + 7 files changed, 236 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce418f75452b..f21e779885a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), and add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms) so emergency stop messages are caught more reliably. (#25103) Thanks @steipete and @vincentkoc. +- Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes). ### Fixes diff --git a/docs/cli/security.md b/docs/cli/security.md index 9b1cce7db792..6f9be145a68a 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -25,6 +25,8 @@ openclaw security audit --json The audit warns when multiple DM senders share the main session and recommends **secure DM mode**: `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes. This is for cooperative/shared inbox hardening. A single Gateway shared by mutually untrusted/adversarial operators is not a recommended setup; split trust boundaries with separate gateways (or separate OS users/hosts). +It also emits `security.trust_model.multi_user_heuristic` when config suggests likely shared-user ingress (for example configured group targets or wildcard sender rules), and reminds you that OpenClaw is a personal-assistant trust model by default. +For intentional shared-user setups, the audit guidance is to sandbox all sessions, keep filesystem access workspace-scoped, and keep personal/private identities or credentials off that runtime. It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled. For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`. It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed extension plugin tools may be reachable under permissive tool policy. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 49b985be2a65..613866bd959f 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -7,6 +7,22 @@ title: "Security" # Security 🔒 +> [!WARNING] +> **Personal assistant trust model:** this guidance assumes one trusted operator boundary per gateway (single-user/personal assistant model). +> OpenClaw is **not** a hostile multi-tenant security boundary for multiple adversarial users sharing one agent/gateway. +> If you need mixed-trust or adversarial-user operation, split trust boundaries (separate gateway + credentials, ideally separate OS users/hosts). + +## Scope first: personal assistant security model + +OpenClaw security guidance assumes a **personal assistant** deployment: one trusted operator boundary, potentially many agents. + +- Supported security posture: one user/trust boundary per gateway (prefer one OS user/host/VPS per boundary). +- Not a supported security boundary: one shared gateway/agent used by mutually untrusted or adversarial users. +- If adversarial-user isolation is required, split by trust boundary (separate gateway + credentials, and ideally separate OS users/hosts). +- If multiple untrusted users can message one tool-enabled agent, treat them as sharing the same delegated tool authority for that agent. + +This page explains hardening **within that model**. It does not claim hostile multi-tenant isolation on one shared gateway. + ## Quick check: `openclaw security audit` See also: [Formal Verification (Security Models)](/security/formal-verification/) diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index e5417a0f9be1..464930d91268 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -338,6 +338,137 @@ function listGroupPolicyOpen(cfg: OpenClawConfig): string[] { return out; } +function hasConfiguredGroupTargets(section: Record): boolean { + const groupKeys = ["groups", "guilds", "channels", "rooms"]; + return groupKeys.some((key) => { + const value = section[key]; + return Boolean(value && typeof value === "object" && Object.keys(value).length > 0); + }); +} + +function listPotentialMultiUserSignals(cfg: OpenClawConfig): string[] { + const out = new Set(); + const channels = cfg.channels as Record | undefined; + if (!channels || typeof channels !== "object") { + return []; + } + + const inspectSection = (section: Record, basePath: string) => { + const groupPolicy = typeof section.groupPolicy === "string" ? section.groupPolicy : null; + if (groupPolicy === "open") { + out.add(`${basePath}.groupPolicy="open"`); + } else if (groupPolicy === "allowlist" && hasConfiguredGroupTargets(section)) { + out.add(`${basePath}.groupPolicy="allowlist" with configured group targets`); + } + + const dmPolicy = typeof section.dmPolicy === "string" ? section.dmPolicy : null; + if (dmPolicy === "open") { + out.add(`${basePath}.dmPolicy="open"`); + } + + const allowFrom = Array.isArray(section.allowFrom) ? section.allowFrom : []; + if (allowFrom.some((entry) => String(entry).trim() === "*")) { + out.add(`${basePath}.allowFrom includes "*"`); + } + + const groupAllowFrom = Array.isArray(section.groupAllowFrom) ? section.groupAllowFrom : []; + if (groupAllowFrom.some((entry) => String(entry).trim() === "*")) { + out.add(`${basePath}.groupAllowFrom includes "*"`); + } + + const dm = section.dm; + if (dm && typeof dm === "object") { + const dmSection = dm as Record; + const dmLegacyPolicy = typeof dmSection.policy === "string" ? dmSection.policy : null; + if (dmLegacyPolicy === "open") { + out.add(`${basePath}.dm.policy="open"`); + } + const dmAllowFrom = Array.isArray(dmSection.allowFrom) ? dmSection.allowFrom : []; + if (dmAllowFrom.some((entry) => String(entry).trim() === "*")) { + out.add(`${basePath}.dm.allowFrom includes "*"`); + } + } + }; + + for (const [channelId, value] of Object.entries(channels)) { + if (!value || typeof value !== "object") { + continue; + } + const section = value as Record; + inspectSection(section, `channels.${channelId}`); + const accounts = section.accounts; + if (!accounts || typeof accounts !== "object") { + continue; + } + for (const [accountId, accountValue] of Object.entries(accounts)) { + if (!accountValue || typeof accountValue !== "object") { + continue; + } + inspectSection( + accountValue as Record, + `channels.${channelId}.accounts.${accountId}`, + ); + } + } + + return Array.from(out); +} + +function collectRiskyToolExposureContexts(cfg: OpenClawConfig): { + riskyContexts: string[]; + hasRuntimeRisk: boolean; +} { + const contexts: Array<{ + label: string; + agentId?: string; + tools?: AgentToolsConfig; + }> = [{ label: "agents.defaults" }]; + for (const agent of cfg.agents?.list ?? []) { + if (!agent || typeof agent !== "object" || typeof agent.id !== "string") { + continue; + } + contexts.push({ + label: `agents.list.${agent.id}`, + agentId: agent.id, + tools: agent.tools, + }); + } + + const riskyContexts: string[] = []; + let hasRuntimeRisk = false; + for (const context of contexts) { + const sandboxMode = resolveSandboxConfigForAgent(cfg, context.agentId).mode; + const policies = resolveToolPolicies({ + cfg, + agentTools: context.tools, + sandboxMode, + agentId: context.agentId ?? null, + }); + const runtimeTools = ["exec", "process"].filter((tool) => + isToolAllowedByPolicies(tool, policies), + ); + const fsTools = ["read", "write", "edit", "apply_patch"].filter((tool) => + isToolAllowedByPolicies(tool, policies), + ); + const fsWorkspaceOnly = context.tools?.fs?.workspaceOnly ?? cfg.tools?.fs?.workspaceOnly; + const runtimeUnguarded = runtimeTools.length > 0 && sandboxMode !== "all"; + const fsUnguarded = fsTools.length > 0 && sandboxMode !== "all" && fsWorkspaceOnly !== true; + if (!runtimeUnguarded && !fsUnguarded) { + continue; + } + if (runtimeUnguarded) { + hasRuntimeRisk = true; + } + riskyContexts.push( + `${context.label} (sandbox=${sandboxMode}; runtime=[${runtimeTools.join(", ") || "off"}]; fs=[${fsTools.join(", ") || "off"}]; fs.workspaceOnly=${ + fsWorkspaceOnly === true ? "true" : "false" + })`, + ); + } + + return { riskyContexts, hasRuntimeRisk }; +} + // -------------------------------------------------------------------------- // Exported collectors // -------------------------------------------------------------------------- @@ -358,7 +489,9 @@ export function collectAttackSurfaceSummaryFindings(cfg: OpenClawConfig): Securi `\n` + `hooks.internal: ${internalHooksEnabled ? "enabled" : "disabled"}` + `\n` + - `browser control: ${browserEnabled ? "enabled" : "disabled"}`; + `browser control: ${browserEnabled ? "enabled" : "disabled"}` + + `\n` + + "trust model: personal assistant (one trusted operator boundary), not hostile multi-tenant on one shared gateway"; return [ { @@ -1096,53 +1229,7 @@ export function collectExposureMatrixFindings(cfg: OpenClawConfig): SecurityAudi }); } - const contexts: Array<{ - label: string; - agentId?: string; - tools?: AgentToolsConfig; - }> = [{ label: "agents.defaults" }]; - for (const agent of cfg.agents?.list ?? []) { - if (!agent || typeof agent !== "object" || typeof agent.id !== "string") { - continue; - } - contexts.push({ - label: `agents.list.${agent.id}`, - agentId: agent.id, - tools: agent.tools, - }); - } - - const riskyContexts: string[] = []; - let hasRuntimeRisk = false; - for (const context of contexts) { - const sandboxMode = resolveSandboxConfigForAgent(cfg, context.agentId).mode; - const policies = resolveToolPolicies({ - cfg, - agentTools: context.tools, - sandboxMode, - agentId: context.agentId ?? null, - }); - const runtimeTools = ["exec", "process"].filter((tool) => - isToolAllowedByPolicies(tool, policies), - ); - const fsTools = ["read", "write", "edit", "apply_patch"].filter((tool) => - isToolAllowedByPolicies(tool, policies), - ); - const fsWorkspaceOnly = context.tools?.fs?.workspaceOnly ?? cfg.tools?.fs?.workspaceOnly; - const runtimeUnguarded = runtimeTools.length > 0 && sandboxMode !== "all"; - const fsUnguarded = fsTools.length > 0 && sandboxMode !== "all" && fsWorkspaceOnly !== true; - if (!runtimeUnguarded && !fsUnguarded) { - continue; - } - if (runtimeUnguarded) { - hasRuntimeRisk = true; - } - riskyContexts.push( - `${context.label} (sandbox=${sandboxMode}; runtime=[${runtimeTools.join(", ") || "off"}]; fs=[${fsTools.join(", ") || "off"}]; fs.workspaceOnly=${ - fsWorkspaceOnly === true ? "true" : "false" - })`, - ); - } + const { riskyContexts, hasRuntimeRisk } = collectRiskyToolExposureContexts(cfg); if (riskyContexts.length > 0) { findings.push({ @@ -1160,3 +1247,35 @@ export function collectExposureMatrixFindings(cfg: OpenClawConfig): SecurityAudi return findings; } + +export function collectLikelyMultiUserSetupFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { + const findings: SecurityAuditFinding[] = []; + const signals = listPotentialMultiUserSignals(cfg); + if (signals.length === 0) { + return findings; + } + + const { riskyContexts, hasRuntimeRisk } = collectRiskyToolExposureContexts(cfg); + const impactLine = hasRuntimeRisk + ? "Runtime/process tools are exposed without full sandboxing in at least one context." + : "No unguarded runtime/process tools were detected by this heuristic."; + const riskyContextsDetail = + riskyContexts.length > 0 + ? `Potential high-impact tool exposure contexts:\n${riskyContexts.map((line) => `- ${line}`).join("\n")}` + : "No unguarded runtime/filesystem contexts detected."; + + findings.push({ + checkId: "security.trust_model.multi_user_heuristic", + severity: "warn", + title: "Potential multi-user setup detected (personal-assistant model warning)", + detail: + "Heuristic signals indicate this gateway may be reachable by multiple users:\n" + + signals.map((signal) => `- ${signal}`).join("\n") + + `\n${impactLine}\n${riskyContextsDetail}\n` + + "OpenClaw's default security model is personal-assistant (one trusted operator boundary), not hostile multi-tenant isolation on one shared gateway.", + remediation: + 'If users may be mutually untrusted, split trust boundaries (separate gateways + credentials, ideally separate OS users/hosts). If you intentionally run shared-user access, set agents.defaults.sandbox.mode="all", keep tools.fs.workspaceOnly=true, deny runtime/fs/web tools unless required, and keep personal/private identities + credentials off that runtime.', + }); + + return findings; +} diff --git a/src/security/audit-extra.ts b/src/security/audit-extra.ts index fa2b82fa150a..9345cb8732ba 100644 --- a/src/security/audit-extra.ts +++ b/src/security/audit-extra.ts @@ -14,6 +14,7 @@ export { collectGatewayHttpNoAuthFindings, collectGatewayHttpSessionKeyOverrideFindings, collectHooksHardeningFindings, + collectLikelyMultiUserSetupFindings, collectMinimalProfileOverrideFindings, collectModelHygieneFindings, collectNodeDangerousAllowCommandFindings, diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 2b4fbebe033e..3b7d54fcb8d2 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -178,12 +178,14 @@ describe("security audit", () => { }; const res = await audit(cfg); + const summary = res.findings.find((f) => f.checkId === "summary.attack_surface"); expect(res.findings).toEqual( expect.arrayContaining([ expect.objectContaining({ checkId: "summary.attack_surface", severity: "info" }), ]), ); + expect(summary?.detail).toContain("trust model: personal assistant"); }); it("flags non-loopback bind without auth as critical", async () => { @@ -2696,6 +2698,51 @@ description: test skill ).toBe(false); }); + it("warns when config heuristics suggest a likely multi-user setup", async () => { + const cfg: OpenClawConfig = { + channels: { + discord: { + groupPolicy: "allowlist", + guilds: { + "1234567890": { + channels: { + "7777777777": { allow: true }, + }, + }, + }, + }, + }, + tools: { elevated: { enabled: false } }, + }; + + const res = await audit(cfg); + const finding = res.findings.find( + (f) => f.checkId === "security.trust_model.multi_user_heuristic", + ); + + expect(finding?.severity).toBe("warn"); + expect(finding?.detail).toContain( + 'channels.discord.groupPolicy="allowlist" with configured group targets', + ); + expect(finding?.detail).toContain("personal-assistant"); + expect(finding?.remediation).toContain('agents.defaults.sandbox.mode="all"'); + }); + + it("does not warn for multi-user heuristic when no shared-user signals are configured", async () => { + const cfg: OpenClawConfig = { + channels: { + discord: { + groupPolicy: "allowlist", + }, + }, + tools: { elevated: { enabled: false } }, + }; + + const res = await audit(cfg); + + expectNoFinding(res, "security.trust_model.multi_user_heuristic"); + }); + describe("maybeProbeGateway auth selection", () => { const makeProbeCapture = () => { let capturedAuth: { token?: string; password?: string } | undefined; diff --git a/src/security/audit.ts b/src/security/audit.ts index 6d4aa90d380b..c1714ca49698 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -24,6 +24,7 @@ import { collectHooksHardeningFindings, collectIncludeFilePermFindings, collectInstalledSkillsCodeSafetyFindings, + collectLikelyMultiUserSetupFindings, collectSandboxBrowserHashLabelFindings, collectMinimalProfileOverrideFindings, collectModelHygieneFindings, @@ -866,6 +867,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise Date: Tue, 24 Feb 2026 21:13:34 +0800 Subject: [PATCH 154/408] fix(agents): await block-reply flush before tool execution starts handleToolExecutionStart() flushed pending block replies and then called onBlockReplyFlush() as fire-and-forget (`void`). This created a race where fast tool results (especially media on Telegram) could be delivered before the text block that preceded the tool call. Await onBlockReplyFlush() so the block pipeline finishes before tool execution continues, preserving delivery order. Fixes #25267 Co-authored-by: Cursor --- ...-embedded-subscribe.handlers.tools.test.ts | 31 +++++++++++++++++++ .../pi-embedded-subscribe.handlers.tools.ts | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts index c03eb00da57e..96a988e5bc61 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts @@ -88,6 +88,37 @@ describe("handleToolExecutionStart read path checks", () => { expect(warn).toHaveBeenCalledTimes(1); expect(String(warn.mock.calls[0]?.[0] ?? "")).toContain("read tool called without path"); }); + + it("awaits onBlockReplyFlush before continuing tool start processing", async () => { + const { ctx, onBlockReplyFlush } = createTestContext(); + let releaseFlush: (() => void) | undefined; + onBlockReplyFlush.mockImplementation( + () => + new Promise((resolve) => { + releaseFlush = resolve; + }), + ); + + const evt: ToolExecutionStartEvent = { + type: "tool_execution_start", + toolName: "exec", + toolCallId: "tool-await-flush", + args: { command: "echo hi" }, + }; + + const pending = handleToolExecutionStart(ctx, evt); + // Let the async function reach the awaited flush Promise. + await Promise.resolve(); + + // If flush isn't awaited, tool metadata would already be recorded here. + expect(ctx.state.toolMetaById.has("tool-await-flush")).toBe(false); + expect(releaseFlush).toBeTypeOf("function"); + + releaseFlush?.(); + await pending; + + expect(ctx.state.toolMetaById.has("tool-await-flush")).toBe(true); + }); }); describe("handleToolExecutionEnd cron.add commitment tracking", () => { diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index ea3031a6cc47..18dc11193f03 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -174,7 +174,7 @@ export async function handleToolExecutionStart( // Flush pending block replies to preserve message boundaries before tool execution. ctx.flushBlockReplyBuffer(); if (ctx.params.onBlockReplyFlush) { - void ctx.params.onBlockReplyFlush(); + await ctx.params.onBlockReplyFlush(); } const rawToolName = String(evt.toolName); From d84659f22fc59d9eecfa6f1cebe24b79674bed5a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:11:18 +0000 Subject: [PATCH 155/408] fix: add changelog for block-reply flush await (#25427) (thanks @SidQin-cyber) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f21e779885a0..e53ecd00ea8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. - macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. - Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase. - CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. From 20523b918adff4feae378ac9965e204c56b6e3d8 Mon Sep 17 00:00:00 2001 From: SidQin-cyber Date: Tue, 24 Feb 2026 21:15:57 +0800 Subject: [PATCH 156/408] fix(gateway): allow trusted-proxy control-ui auth to skip device pairing Control UI connections authenticated via gateway.auth.mode=trusted-proxy were still forced through device pairing because pairing bypass only considered shared token/password auth (sharedAuthOk). In trusted-proxy deployments, this produced persistent "pairing required" failures despite valid trusted proxy headers. Treat authenticated trusted-proxy control-ui connections as pairing-bypass eligible and allow missing device identity in that mode. Fixes #25293 Co-authored-by: Cursor --- .../ws-connection/connect-policy.test.ts | 31 ++++++++++++++++--- .../server/ws-connection/connect-policy.ts | 8 +++++ .../server/ws-connection/message-handler.ts | 13 +++++++- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index e0b691fecdcb..320f90537cee 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -49,6 +49,7 @@ describe("ws connect policy", () => { role: "node", isControlUi: false, controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, @@ -68,6 +69,7 @@ describe("ws connect policy", () => { role: "operator", isControlUi: true, controlUiAuthPolicy: controlUiStrict, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, @@ -82,6 +84,7 @@ describe("ws connect policy", () => { role: "operator", isControlUi: true, controlUiAuthPolicy: controlUiStrict, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, @@ -101,6 +104,7 @@ describe("ws connect policy", () => { role: "operator", isControlUi: true, controlUiAuthPolicy: controlUiNoInsecure, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, @@ -114,6 +118,7 @@ describe("ws connect policy", () => { role: "operator", isControlUi: false, controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, @@ -127,6 +132,7 @@ describe("ws connect policy", () => { role: "operator", isControlUi: false, controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, sharedAuthOk: false, authOk: false, hasSharedAuth: true, @@ -140,15 +146,31 @@ describe("ws connect policy", () => { role: "node", isControlUi: false, controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, isLocalClient: false, }).kind, ).toBe("reject-device-required"); + + // Trusted-proxy authenticated Control UI should bypass device-identity gating. + expect( + evaluateMissingDeviceIdentity({ + hasDeviceIdentity: false, + role: "operator", + isControlUi: true, + controlUiAuthPolicy: controlUiNoInsecure, + trustedProxyAuthOk: true, + sharedAuthOk: false, + authOk: true, + hasSharedAuth: false, + isLocalClient: false, + }).kind, + ).toBe("allow"); }); - test("pairing bypass requires control-ui bypass + shared auth", () => { + test("pairing bypass requires control-ui bypass + shared auth (or trusted-proxy auth)", () => { const bypass = resolveControlUiAuthPolicy({ isControlUi: true, controlUiConfig: { dangerouslyDisableDeviceAuth: true }, @@ -159,8 +181,9 @@ describe("ws connect policy", () => { controlUiConfig: undefined, deviceRaw: null, }); - expect(shouldSkipControlUiPairing(bypass, true)).toBe(true); - expect(shouldSkipControlUiPairing(bypass, false)).toBe(false); - expect(shouldSkipControlUiPairing(strict, true)).toBe(false); + expect(shouldSkipControlUiPairing(bypass, true, false)).toBe(true); + expect(shouldSkipControlUiPairing(bypass, false, false)).toBe(false); + expect(shouldSkipControlUiPairing(strict, true, false)).toBe(false); + expect(shouldSkipControlUiPairing(strict, false, true)).toBe(true); }); }); diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index b52cb066411b..70dbea075058 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -35,7 +35,11 @@ export function resolveControlUiAuthPolicy(params: { export function shouldSkipControlUiPairing( policy: ControlUiAuthPolicy, sharedAuthOk: boolean, + trustedProxyAuthOk = false, ): boolean { + if (trustedProxyAuthOk) { + return true; + } return policy.allowBypass && sharedAuthOk; } @@ -50,6 +54,7 @@ export function evaluateMissingDeviceIdentity(params: { role: GatewayRole; isControlUi: boolean; controlUiAuthPolicy: ControlUiAuthPolicy; + trustedProxyAuthOk?: boolean; sharedAuthOk: boolean; authOk: boolean; hasSharedAuth: boolean; @@ -58,6 +63,9 @@ export function evaluateMissingDeviceIdentity(params: { if (params.hasDeviceIdentity) { return { kind: "allow" }; } + if (params.isControlUi && params.trustedProxyAuthOk) { + return { kind: "allow" }; + } if (params.isControlUi && !params.controlUiAuthPolicy.allowBypass) { // Allow localhost Control UI connections when allowInsecureAuth is configured. // Localhost has no network interception risk, and browser SubtleCrypto diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 1798b71afb42..191278275ee8 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -427,11 +427,17 @@ export function attachGatewayWsMessageHandler(params: { if (!device) { clearUnboundScopes(); } + const trustedProxyAuthOk = + isControlUi && + resolvedAuth.mode === "trusted-proxy" && + authOk && + authMethod === "trusted-proxy"; const decision = evaluateMissingDeviceIdentity({ hasDeviceIdentity: Boolean(device), role, isControlUi, controlUiAuthPolicy, + trustedProxyAuthOk, sharedAuthOk, authOk, hasSharedAuth, @@ -563,8 +569,13 @@ export function attachGatewayWsMessageHandler(params: { // In that case, don't force device pairing on first connect. const skipPairingForOperatorSharedAuth = role === "operator" && sharedAuthOk && !isControlUi && !isWebchat; + const trustedProxyAuthOk = + isControlUi && + resolvedAuth.mode === "trusted-proxy" && + authOk && + authMethod === "trusted-proxy"; const skipPairing = - shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk) || + shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk, trustedProxyAuthOk) || skipPairingForOperatorSharedAuth; if (device && devicePublicKey && !skipPairing) { const formatAuditList = (items: string[] | undefined): string => { From e9216cb7dc0e4a7fb3ade7933e57d71016e73349 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:17:28 +0000 Subject: [PATCH 157/408] fix: add changelog for trusted-proxy pairing bypass (#25428) (thanks @SidQin-cyber) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e53ecd00ea8d..390bbb0f65d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. - Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. - macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. - Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase. From 0f0b2c0255e7d683ab79d5e5b8526c142ceb3bd7 Mon Sep 17 00:00:00 2001 From: Marcus Widing Date: Tue, 24 Feb 2026 10:26:03 +0100 Subject: [PATCH 158/408] fix(exec): match bare * wildcard in allowlist entries (#25082) The matchAllowlist() function skipped patterns without path separators (/, \, ~), causing a bare "*" wildcard entry to never reach the glob matcher. Since glob's single * maps to [^/]*, it would also fail against absolute paths. Handle bare "*" as a special case that matches any resolved executable path. Closes #25082 --- src/infra/exec-approvals.test.ts | 36 ++++++++++++++++++++++++++++ src/infra/exec-command-resolution.ts | 18 +++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 6b405b466d31..407261f43a35 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -43,6 +43,22 @@ describe("exec approvals allowlist matching", () => { } }); + it("matches bare * wildcard pattern against any resolved path", () => { + const match = matchAllowlist([{ pattern: "*" }], baseResolution); + expect(match).not.toBeNull(); + expect(match?.pattern).toBe("*"); + }); + + it("matches bare * wildcard against arbitrary executables", () => { + const match = matchAllowlist([{ pattern: "*" }], { + rawExecutable: "python3", + resolvedPath: "/usr/bin/python3", + executableName: "python3", + }); + expect(match).not.toBeNull(); + expect(match?.pattern).toBe("*"); + }); + it("requires a resolved path", () => { const match = matchAllowlist([{ pattern: "bin/rg" }], { rawExecutable: "bin/rg", @@ -543,6 +559,26 @@ describe("exec approvals shell allowlist (chained commands)", () => { expect(result.analysisOk).toBe(false); expect(result.allowlistSatisfied).toBe(false); }); + + it("satisfies allowlist when bare * wildcard is present", () => { + const dir = makeTempDir(); + const binPath = path.join(dir, "mybin"); + fs.writeFileSync(binPath, "#!/bin/sh\n", { mode: 0o755 }); + const env = makePathEnv(dir); + try { + const result = evaluateShellAllowlist({ + command: "mybin --flag", + allowlist: [{ pattern: "*" }], + safeBins: new Set(), + cwd: dir, + env, + }); + expect(result.analysisOk).toBe(true); + expect(result.allowlistSatisfied).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); describe("exec approvals allowlist evaluation", () => { diff --git a/src/infra/exec-command-resolution.ts b/src/infra/exec-command-resolution.ts index 3dceb0fc598d..c2c2f3fe2305 100644 --- a/src/infra/exec-command-resolution.ts +++ b/src/infra/exec-command-resolution.ts @@ -223,7 +223,17 @@ export function matchAllowlist( entries: ExecAllowlistEntry[], resolution: CommandResolution | null, ): ExecAllowlistEntry | null { - if (!entries.length || !resolution?.resolvedPath) { + if (!entries.length) { + return null; + } + // A bare "*" wildcard allows any command regardless of resolution. + // Check it before the resolvedPath guard so that unresolvable commands + // (e.g. Windows executables without known extensions) still match. + const bareWild = entries.find((e) => e.pattern?.trim() === "*"); + if (bareWild && resolution) { + return bareWild; + } + if (!resolution?.resolvedPath) { return null; } const resolvedPath = resolution.resolvedPath; @@ -232,6 +242,12 @@ export function matchAllowlist( if (!pattern) { continue; } + // A bare "*" wildcard means "allow any executable". Match immediately + // without going through glob expansion (glob `*` maps to `[^/]*` which + // would fail on absolute paths containing slashes). + if (pattern === "*") { + return entry; + } const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~"); if (!hasPath) { continue; From 07f653ffc8e2ffb042d1ac3a5a0dbf08681b18ad Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:19:55 +0000 Subject: [PATCH 159/408] fix: polish bare wildcard allowlist handling (#25250) (thanks @widingmarcus-cyber) --- CHANGELOG.md | 1 + src/infra/exec-command-resolution.ts | 12 +++--------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 390bbb0f65d8..9b1a3e15899e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber. - Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. - Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. - macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. diff --git a/src/infra/exec-command-resolution.ts b/src/infra/exec-command-resolution.ts index c2c2f3fe2305..d102a1030f1e 100644 --- a/src/infra/exec-command-resolution.ts +++ b/src/infra/exec-command-resolution.ts @@ -226,9 +226,9 @@ export function matchAllowlist( if (!entries.length) { return null; } - // A bare "*" wildcard allows any command regardless of resolution. - // Check it before the resolvedPath guard so that unresolvable commands - // (e.g. Windows executables without known extensions) still match. + // A bare "*" wildcard allows any parsed executable command. + // Check it before the resolvedPath guard so unresolved PATH lookups still + // match (for example platform-specific executables without known extensions). const bareWild = entries.find((e) => e.pattern?.trim() === "*"); if (bareWild && resolution) { return bareWild; @@ -242,12 +242,6 @@ export function matchAllowlist( if (!pattern) { continue; } - // A bare "*" wildcard means "allow any executable". Match immediately - // without going through glob expansion (glob `*` maps to `[^/]*` which - // would fail on absolute paths containing slashes). - if (pattern === "*") { - return entry; - } const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~"); if (!hasPath) { continue; From b863316e7b9d03c6ba55aa06ac9119f007eb0fef Mon Sep 17 00:00:00 2001 From: lbo728 Date: Tue, 24 Feb 2026 18:57:37 +0900 Subject: [PATCH 160/408] fix(models): preserve user reasoning override when merging with built-in catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a built-in provider model has reasoning:true (e.g. MiniMax-M2.5) and the user explicitly sets reasoning:false in their config, mergeProviderModels unconditionally overwrote the user's value with the built-in catalog value. The merge code refreshes capability metadata (input, contextWindow, maxTokens, reasoning) from the implicit catalog. This is correct for fields like contextWindow and maxTokens — the catalog has authoritative values that shouldn't be stale. But reasoning is a user preference, not just a capability descriptor: users may need to disable it to avoid 'Message ordering conflict' errors with certain models or backends. Fix: check whether 'reasoning' is present in the explicit (user-supplied) model entry. If the user has set it (even to false), honour that value. If the user hasn't set it, fall back to the built-in catalog default. This allows users to configure tools.models.providers.minimax.models with reasoning:false for MiniMax-M2.5 without being silently overridden. Fixes #25244 --- ...serves-explicit-reasoning-override.test.ts | 119 ++++++++++++++++++ src/agents/models-config.ts | 6 +- 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 src/agents/models-config.preserves-explicit-reasoning-override.test.ts diff --git a/src/agents/models-config.preserves-explicit-reasoning-override.test.ts b/src/agents/models-config.preserves-explicit-reasoning-override.test.ts new file mode 100644 index 000000000000..455d43b4e7ba --- /dev/null +++ b/src/agents/models-config.preserves-explicit-reasoning-override.test.ts @@ -0,0 +1,119 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveOpenClawAgentDir } from "./agent-paths.js"; +import { + installModelsConfigTestHooks, + withModelsTempHome as withTempHome, +} from "./models-config.e2e-harness.js"; +import { ensureOpenClawModelsJson } from "./models-config.js"; + +installModelsConfigTestHooks(); + +type ModelEntry = { + id: string; + reasoning?: boolean; + contextWindow?: number; + maxTokens?: number; +}; + +type ModelsJson = { + providers: Record; +}; + +describe("models-config: explicit reasoning override", () => { + it("preserves user reasoning:false when built-in catalog has reasoning:true (MiniMax-M2.5)", async () => { + // MiniMax-M2.5 has reasoning:true in the built-in catalog. + // User explicitly sets reasoning:false to avoid message-ordering conflicts. + await withTempHome(async () => { + const prevKey = process.env.MINIMAX_API_KEY; + process.env.MINIMAX_API_KEY = "sk-minimax-test"; + try { + const cfg: OpenClawConfig = { + models: { + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", + models: [ + { + id: "MiniMax-M2.5", + name: "MiniMax M2.5", + reasoning: false, // explicit override: user wants to disable reasoning + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 8192, + }, + ], + }, + }, + }, + }; + + await ensureOpenClawModelsJson(cfg); + + const raw = await fs.readFile(path.join(resolveOpenClawAgentDir(), "models.json"), "utf8"); + const parsed = JSON.parse(raw) as ModelsJson; + const m25 = parsed.providers.minimax?.models?.find((m) => m.id === "MiniMax-M2.5"); + expect(m25).toBeDefined(); + // Must honour the explicit false — built-in true must NOT win. + expect(m25?.reasoning).toBe(false); + } finally { + if (prevKey === undefined) { + delete process.env.MINIMAX_API_KEY; + } else { + process.env.MINIMAX_API_KEY = prevKey; + } + } + }); + }); + + it("falls back to built-in reasoning:true when user omits the field (MiniMax-M2.5)", async () => { + // When the user does not set reasoning at all, the built-in catalog value + // (true for MiniMax-M2.5) should be used so the model works out of the box. + await withTempHome(async () => { + const prevKey = process.env.MINIMAX_API_KEY; + process.env.MINIMAX_API_KEY = "sk-minimax-test"; + try { + // Omit 'reasoning' to simulate a user config that doesn't set it. + const modelWithoutReasoning = { + id: "MiniMax-M2.5", + name: "MiniMax M2.5", + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 8192, + }; + const cfg: OpenClawConfig = { + models: { + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + models: [modelWithoutReasoning as any], + }, + }, + }, + }; + + await ensureOpenClawModelsJson(cfg); + + const raw = await fs.readFile(path.join(resolveOpenClawAgentDir(), "models.json"), "utf8"); + const parsed = JSON.parse(raw) as ModelsJson; + const m25 = parsed.providers.minimax?.models?.find((m) => m.id === "MiniMax-M2.5"); + expect(m25).toBeDefined(); + // Built-in catalog has reasoning:true — should be applied as default. + expect(m25?.reasoning).toBe(true); + } finally { + if (prevKey === undefined) { + delete process.env.MINIMAX_API_KEY; + } else { + process.env.MINIMAX_API_KEY = prevKey; + } + } + }); + }); +}); diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 5ca971646e18..4b38b8243984 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -47,10 +47,14 @@ function mergeProviderModels(implicit: ProviderConfig, explicit: ProviderConfig) // Refresh capability metadata from the implicit catalog while preserving // user-specific fields (cost, headers, compat, etc.) on explicit entries. + // reasoning is treated as user-overridable: if the user has explicitly set + // it in their config (key present), honour that value; otherwise fall back + // to the built-in catalog default so new reasoning models work out of the + // box without requiring every user to configure it. return { ...explicitModel, input: implicitModel.input, - reasoning: implicitModel.reasoning, + reasoning: "reasoning" in explicitModel ? explicitModel.reasoning : implicitModel.reasoning, contextWindow: implicitModel.contextWindow, maxTokens: implicitModel.maxTokens, }; From 39631639b7799eaaf45d7fdae6d209a86be281fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:21:52 +0000 Subject: [PATCH 161/408] fix: add changelog + typed omission test note (#25314) (thanks @lbo728) --- CHANGELOG.md | 1 + ...odels-config.preserves-explicit-reasoning-override.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b1a3e15899e..17d8ff8c3642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728. - Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber. - Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. - Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. diff --git a/src/agents/models-config.preserves-explicit-reasoning-override.test.ts b/src/agents/models-config.preserves-explicit-reasoning-override.test.ts index 455d43b4e7ba..6a3601aa8945 100644 --- a/src/agents/models-config.preserves-explicit-reasoning-override.test.ts +++ b/src/agents/models-config.preserves-explicit-reasoning-override.test.ts @@ -92,8 +92,8 @@ describe("models-config: explicit reasoning override", () => { minimax: { baseUrl: "https://api.minimax.io/anthropic", api: "anthropic-messages", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - models: [modelWithoutReasoning as any], + // @ts-expect-error Intentional: emulate user config omitting reasoning. + models: [modelWithoutReasoning], }, }, }, From 8cc841766cdf2ba2913f303c45915bab6ae3dc05 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:25:34 +0000 Subject: [PATCH 162/408] docs(security): enumerate dangerous config parameters --- docs/cli/security.md | 3 ++- docs/gateway/security/index.md | 42 +++++++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/docs/cli/security.md b/docs/cli/security.md index 6f9be145a68a..b962ebef675d 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -25,7 +25,7 @@ openclaw security audit --json The audit warns when multiple DM senders share the main session and recommends **secure DM mode**: `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes. This is for cooperative/shared inbox hardening. A single Gateway shared by mutually untrusted/adversarial operators is not a recommended setup; split trust boundaries with separate gateways (or separate OS users/hosts). -It also emits `security.trust_model.multi_user_heuristic` when config suggests likely shared-user ingress (for example configured group targets or wildcard sender rules), and reminds you that OpenClaw is a personal-assistant trust model by default. +It also emits `security.trust_model.multi_user_heuristic` when config suggests likely shared-user ingress (for example open DM/group policy, configured group targets, or wildcard sender rules), and reminds you that OpenClaw is a personal-assistant trust model by default. For intentional shared-user setups, the audit guidance is to sandbox all sessions, keep filesystem access workspace-scoped, and keep personal/private identities or credentials off that runtime. It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled. For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`. @@ -37,6 +37,7 @@ It also warns when npm-based plugin/hook install records are unpinned, missing i It warns when channel allowlists rely on mutable names/emails/tags instead of stable IDs (Discord, Slack, Google Chat, MS Teams, Mattermost, IRC scopes where applicable). It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint). Settings prefixed with `dangerous`/`dangerously` are explicit break-glass operator overrides; enabling one is not, by itself, a security vulnerability report. +For the complete dangerous-parameter inventory, see the "Insecure or dangerous flags summary" section in [Security](/gateway/security). ## JSON output diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 613866bd959f..330555d2ddff 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -247,7 +247,9 @@ High-signal `checkId` values you will most likely see in real deployments (not e | `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no | | `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no | | `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no | +| `security.exposure.open_groups_with_elevated` | critical | Open groups + elevated tools create high-impact prompt-injection paths | `channels.*.groupPolicy`, `tools.elevated.*` | no | | `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no | +| `security.trust_model.multi_user_heuristic` | warn | Config looks multi-user while gateway trust model is personal-assistant | split trust boundaries, or shared-user hardening (`sandbox.mode`, tool deny/workspace scoping) | no | | `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no | | `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no | | `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no | @@ -267,14 +269,38 @@ keep it off unless you are actively debugging and can revert quickly. ## Insecure or dangerous flags summary -`openclaw security audit` includes `config.insecure_or_dangerous_flags` when any -insecure/dangerous debug switches are enabled. This warning aggregates the exact -keys so you can review them in one place (for example -`gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true`, -`gateway.controlUi.allowInsecureAuth=true`, -`gateway.controlUi.dangerouslyDisableDeviceAuth=true`, -`hooks.gmail.allowUnsafeExternalContent=true`, or -`tools.exec.applyPatch.workspaceOnly=false`). +`openclaw security audit` includes `config.insecure_or_dangerous_flags` when +known insecure/dangerous debug switches are enabled. That check currently +aggregates: + +- `gateway.controlUi.allowInsecureAuth=true` +- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` +- `gateway.controlUi.dangerouslyDisableDeviceAuth=true` +- `hooks.gmail.allowUnsafeExternalContent=true` +- `hooks.mappings[].allowUnsafeExternalContent=true` +- `tools.exec.applyPatch.workspaceOnly=false` + +Complete `dangerous*` / `dangerously*` config keys defined in OpenClaw config +schema: + +- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` +- `gateway.controlUi.dangerouslyDisableDeviceAuth` +- `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` +- `channels.discord.dangerouslyAllowNameMatching` +- `channels.discord.accounts..dangerouslyAllowNameMatching` +- `channels.slack.dangerouslyAllowNameMatching` +- `channels.slack.accounts..dangerouslyAllowNameMatching` +- `channels.googlechat.dangerouslyAllowNameMatching` +- `channels.googlechat.accounts..dangerouslyAllowNameMatching` +- `channels.msteams.dangerouslyAllowNameMatching` +- `channels.irc.dangerouslyAllowNameMatching` (extension channel) +- `channels.irc.accounts..dangerouslyAllowNameMatching` (extension channel) +- `channels.mattermost.dangerouslyAllowNameMatching` (extension channel) +- `channels.mattermost.accounts..dangerouslyAllowNameMatching` (extension channel) +- `agents.defaults.sandbox.docker.dangerouslyAllowReservedContainerTargets` +- `agents.defaults.sandbox.docker.dangerouslyAllowExternalBindSources` +- `agents.list[].sandbox.docker.dangerouslyAllowReservedContainerTargets` +- `agents.list[].sandbox.docker.dangerouslyAllowExternalBindSources` ## Reverse Proxy Configuration From f33d0a884e96827e8a48fabc4d6b1300287749b6 Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Tue, 24 Feb 2026 10:51:37 -0300 Subject: [PATCH 163/408] fix(slack): override wrong channel_type for D-prefix DM channels --- src/slack/monitor/context.ts | 7 +- .../monitor/message-handler/prepare.test.ts | 158 ++++++++++++++++++ src/slack/monitor/monitor.test.ts | 19 +++ 3 files changed, 183 insertions(+), 1 deletion(-) diff --git a/src/slack/monitor/context.ts b/src/slack/monitor/context.ts index ecf049749370..f43f77a0d763 100644 --- a/src/slack/monitor/context.ts +++ b/src/slack/monitor/context.ts @@ -38,15 +38,20 @@ export function normalizeSlackChannelType( channelId?: string | null, ): SlackMessageEvent["channel_type"] { const normalized = channelType?.trim().toLowerCase(); + const inferred = inferSlackChannelType(channelId); if ( normalized === "im" || normalized === "mpim" || normalized === "channel" || normalized === "group" ) { + // D-prefix channel IDs are always DMs — override a contradicting channel_type. + if (inferred === "im" && normalized !== "im") { + return "im"; + } return normalized; } - return inferSlackChannelType(channelId) ?? "channel"; + return inferred ?? "channel"; } export type SlackMonitorContext = { diff --git a/src/slack/monitor/message-handler/prepare.test.ts b/src/slack/monitor/message-handler/prepare.test.ts index 07e6b8345015..654b02f3ce17 100644 --- a/src/slack/monitor/message-handler/prepare.test.ts +++ b/src/slack/monitor/message-handler/prepare.test.ts @@ -264,6 +264,164 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(untrusted).toContain("Do dangerous things"); }); + it("classifies D-prefix DMs correctly even when channel_type is wrong", async () => { + const slackCtx = createSlackMonitorContext({ + cfg: { + channels: { slack: { enabled: true } }, + session: { dmScope: "main" }, + } as OpenClawConfig, + accountId: "default", + botToken: "token", + app: { client: {} } as App, + runtime: {} as RuntimeEnv, + botUserId: "B1", + teamId: "T1", + apiAppId: "A1", + historyLimit: 0, + sessionScope: "per-sender", + mainKey: "main", + dmEnabled: true, + dmPolicy: "open", + allowFrom: [], + groupDmEnabled: true, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open", + useAccessGroups: false, + reactionMode: "off", + reactionAllowlist: [], + replyToMode: "off", + threadHistoryScope: "thread", + threadInheritParent: false, + slashCommand: { + enabled: false, + name: "openclaw", + sessionPrefix: "slack:slash", + ephemeral: true, + }, + textLimit: 4000, + ackReactionScope: "group-mentions", + mediaMaxBytes: 1024, + removeAckAfterReply: false, + }); + // oxlint-disable-next-line typescript/no-explicit-any + slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any; + // Simulate API returning correct type for DM channel + slackCtx.resolveChannelName = async () => ({ name: undefined, type: "im" as const }); + + const account: ResolvedSlackAccount = { + accountId: "default", + enabled: true, + botTokenSource: "config", + appTokenSource: "config", + config: {}, + }; + + // Bug scenario: D-prefix channel but Slack event says channel_type: "channel" + const message: SlackMessageEvent = { + channel: "D0ACP6B1T8V", + channel_type: "channel", + user: "U1", + text: "hello from DM", + ts: "1.000", + } as SlackMessageEvent; + + const prepared = await prepareSlackMessage({ + ctx: slackCtx, + account, + message, + opts: { source: "message" }, + }); + + expect(prepared).toBeTruthy(); + // oxlint-disable-next-line typescript/no-explicit-any + expectInboundContextContract(prepared!.ctxPayload as any); + // Should be classified as DM, not channel + expect(prepared!.isDirectMessage).toBe(true); + // DM with dmScope: "main" should route to the main session + expect(prepared!.route.sessionKey).toBe("agent:main:main"); + // ChatType should be "direct", not "channel" + expect(prepared!.ctxPayload.ChatType).toBe("direct"); + // From should use user ID (DM pattern), not channel ID + expect(prepared!.ctxPayload.From).toContain("slack:U1"); + }); + + it("classifies D-prefix DMs when channel_type is missing", async () => { + const slackCtx = createSlackMonitorContext({ + cfg: { + channels: { slack: { enabled: true } }, + session: { dmScope: "main" }, + } as OpenClawConfig, + accountId: "default", + botToken: "token", + app: { client: {} } as App, + runtime: {} as RuntimeEnv, + botUserId: "B1", + teamId: "T1", + apiAppId: "A1", + historyLimit: 0, + sessionScope: "per-sender", + mainKey: "main", + dmEnabled: true, + dmPolicy: "open", + allowFrom: [], + groupDmEnabled: true, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open", + useAccessGroups: false, + reactionMode: "off", + reactionAllowlist: [], + replyToMode: "off", + threadHistoryScope: "thread", + threadInheritParent: false, + slashCommand: { + enabled: false, + name: "openclaw", + sessionPrefix: "slack:slash", + ephemeral: true, + }, + textLimit: 4000, + ackReactionScope: "group-mentions", + mediaMaxBytes: 1024, + removeAckAfterReply: false, + }); + // oxlint-disable-next-line typescript/no-explicit-any + slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any; + // Simulate API returning correct type for DM channel + slackCtx.resolveChannelName = async () => ({ name: undefined, type: "im" as const }); + + const account: ResolvedSlackAccount = { + accountId: "default", + enabled: true, + botTokenSource: "config", + appTokenSource: "config", + config: {}, + }; + + // channel_type missing — should infer from D-prefix + const message: SlackMessageEvent = { + channel: "D0ACP6B1T8V", + user: "U1", + text: "hello from DM", + ts: "1.000", + } as SlackMessageEvent; + + const prepared = await prepareSlackMessage({ + ctx: slackCtx, + account, + message, + opts: { source: "message" }, + }); + + expect(prepared).toBeTruthy(); + // oxlint-disable-next-line typescript/no-explicit-any + expectInboundContextContract(prepared!.ctxPayload as any); + expect(prepared!.isDirectMessage).toBe(true); + expect(prepared!.route.sessionKey).toBe("agent:main:main"); + expect(prepared!.ctxPayload.ChatType).toBe("direct"); + }); + it("sets MessageThreadId for top-level messages when replyToMode=all", async () => { const slackCtx = createInboundSlackCtx({ cfg: { diff --git a/src/slack/monitor/monitor.test.ts b/src/slack/monitor/monitor.test.ts index 2a6072d93dd8..3262873718da 100644 --- a/src/slack/monitor/monitor.test.ts +++ b/src/slack/monitor/monitor.test.ts @@ -134,6 +134,25 @@ describe("normalizeSlackChannelType", () => { it("prefers explicit channel_type values", () => { expect(normalizeSlackChannelType("mpim", "C123")).toBe("mpim"); }); + + it("overrides wrong channel_type for D-prefix DM channels", () => { + // Slack DM channel IDs always start with "D" — if the event + // reports a wrong channel_type, the D-prefix should win. + expect(normalizeSlackChannelType("channel", "D123")).toBe("im"); + expect(normalizeSlackChannelType("group", "D456")).toBe("im"); + expect(normalizeSlackChannelType("mpim", "D789")).toBe("im"); + }); + + it("preserves correct channel_type for D-prefix DM channels", () => { + expect(normalizeSlackChannelType("im", "D123")).toBe("im"); + }); + + it("does not override G-prefix channel_type (ambiguous prefix)", () => { + // G-prefix can be either "group" (private channel) or "mpim" (group DM) + // — trust the provided channel_type since the prefix is ambiguous. + expect(normalizeSlackChannelType("group", "G123")).toBe("group"); + expect(normalizeSlackChannelType("mpim", "G456")).toBe("mpim"); + }); }); describe("resolveSlackSystemEventSessionKey", () => { From 3ff6e078ec823b1cd5931aed3a12019f116b9917 Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Tue, 24 Feb 2026 10:53:11 -0300 Subject: [PATCH 164/408] test(slack): add missing allowNameMatching field to DM classification tests --- src/slack/monitor/message-handler/prepare.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/slack/monitor/message-handler/prepare.test.ts b/src/slack/monitor/message-handler/prepare.test.ts index 654b02f3ce17..3e8e8b2e8477 100644 --- a/src/slack/monitor/message-handler/prepare.test.ts +++ b/src/slack/monitor/message-handler/prepare.test.ts @@ -283,6 +283,7 @@ describe("slack prepareSlackMessage inbound contract", () => { dmEnabled: true, dmPolicy: "open", allowFrom: [], + allowNameMatching: false, groupDmEnabled: true, groupDmChannels: [], defaultRequireMention: true, @@ -365,6 +366,7 @@ describe("slack prepareSlackMessage inbound contract", () => { dmEnabled: true, dmPolicy: "open", allowFrom: [], + allowNameMatching: false, groupDmEnabled: true, groupDmChannels: [], defaultRequireMention: true, From 5e6fe9c160b6dc4052a4be9807db4eca8a974f38 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:25:50 +0000 Subject: [PATCH 165/408] fix: add changelog for slack dm channel-type guard (#25479) (thanks @mcaxtr) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17d8ff8c3642..7a4b4b767813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr. - Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728. - Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber. - Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. From d32298cbd81619e6c2f8d9725c82898f9e4284cd Mon Sep 17 00:00:00 2001 From: SudeepMalipeddi Date: Tue, 24 Feb 2026 19:39:16 +0530 Subject: [PATCH 166/408] fix: slug-generator uses effective model instead of agent-primary resolveAgentModelPrimary() only checks the agent-level model config and does not fall back to the system-wide default. When users configure a non-Anthropic provider (e.g. Gemini, Minimax) as their global default without setting it at the agent level, the slug-generator falls through to DEFAULT_PROVIDER (anthropic) and fails with a missing API key error. Switch to resolveAgentEffectiveModelPrimary() which correctly respects the full model resolution chain including global defaults. Fixes #25365 --- src/hooks/llm-slug-generator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/llm-slug-generator.ts b/src/hooks/llm-slug-generator.ts index 33c69dcf5ed4..eb355fc3289b 100644 --- a/src/hooks/llm-slug-generator.ts +++ b/src/hooks/llm-slug-generator.ts @@ -9,7 +9,7 @@ import { resolveDefaultAgentId, resolveAgentWorkspaceDir, resolveAgentDir, - resolveAgentModelPrimary, + resolveAgentEffectiveModelPrimary, } from "../agents/agent-scope.js"; import { DEFAULT_PROVIDER, DEFAULT_MODEL } from "../agents/defaults.js"; import { parseModelRef } from "../agents/model-selection.js"; @@ -45,7 +45,7 @@ ${params.sessionContent.slice(0, 2000)} Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", "bug-fix"`; // Resolve model from agent config instead of using hardcoded defaults - const modelRef = resolveAgentModelPrimary(params.cfg, agentId); + const modelRef = resolveAgentEffectiveModelPrimary(params.cfg, agentId); const parsed = modelRef ? parseModelRef(modelRef, DEFAULT_PROVIDER) : null; const provider = parsed?.provider ?? DEFAULT_PROVIDER; const model = parsed?.model ?? DEFAULT_MODEL; From bbdf895d429cedbd208eb5d643a20eb60613e151 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:26:39 +0000 Subject: [PATCH 167/408] fix: add changelog for slug generator model resolution (#25485) (thanks @SudeepMalipeddi) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4b4b767813..af9185f763a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi. - Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr. - Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728. - Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber. From aec41a588bb25c78cbff27fc8a4a69c8e6c19eaf Mon Sep 17 00:00:00 2001 From: chilu18 Date: Tue, 24 Feb 2026 13:48:12 +0000 Subject: [PATCH 168/408] fix(hooks): backfill reset command hooks for native /new path --- src/auto-reply/reply/commands-core.ts | 168 +++++++----- src/auto-reply/reply/commands-types.ts | 2 + src/auto-reply/reply/commands.test.ts | 31 +++ .../get-reply.reset-hooks-fallback.test.ts | 251 ++++++++++++++++++ src/auto-reply/reply/get-reply.ts | 24 ++ 5 files changed, 406 insertions(+), 70 deletions(-) create mode 100644 src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 40f1d49e75b8..229cf7f9eb10 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -39,6 +39,96 @@ import { routeReply } from "./route-reply.js"; let HANDLERS: CommandHandler[] | null = null; +export type ResetCommandAction = "new" | "reset"; + +export async function emitResetCommandHooks(params: { + action: ResetCommandAction; + ctx: HandleCommandsParams["ctx"]; + cfg: HandleCommandsParams["cfg"]; + command: Pick< + HandleCommandsParams["command"], + "surface" | "senderId" | "channel" | "from" | "to" | "resetHookTriggered" + >; + sessionKey?: string; + sessionEntry?: HandleCommandsParams["sessionEntry"]; + previousSessionEntry?: HandleCommandsParams["previousSessionEntry"]; + workspaceDir: string; +}): Promise { + const hookEvent = createInternalHookEvent("command", params.action, params.sessionKey ?? "", { + sessionEntry: params.sessionEntry, + previousSessionEntry: params.previousSessionEntry, + commandSource: params.command.surface, + senderId: params.command.senderId, + cfg: params.cfg, // Pass config for LLM slug generation + }); + await triggerInternalHook(hookEvent); + params.command.resetHookTriggered = true; + + // Send hook messages immediately if present + if (hookEvent.messages.length > 0) { + // Use OriginatingChannel/To if available, otherwise fall back to command channel/from + // oxlint-disable-next-line typescript/no-explicit-any + const channel = params.ctx.OriginatingChannel || (params.command.channel as any); + // For replies, use 'from' (the sender) not 'to' (which might be the bot itself) + const to = params.ctx.OriginatingTo || params.command.from || params.command.to; + + if (channel && to) { + const hookReply = { text: hookEvent.messages.join("\n\n") }; + await routeReply({ + payload: hookReply, + channel: channel, + to: to, + sessionKey: params.sessionKey, + accountId: params.ctx.AccountId, + threadId: params.ctx.MessageThreadId, + cfg: params.cfg, + }); + } + } + + // Fire before_reset plugin hook — extract memories before session history is lost + const hookRunner = getGlobalHookRunner(); + if (hookRunner?.hasHooks("before_reset")) { + const prevEntry = params.previousSessionEntry; + const sessionFile = prevEntry?.sessionFile; + // Fire-and-forget: read old session messages and run hook + void (async () => { + try { + const messages: unknown[] = []; + if (sessionFile) { + const content = await fs.readFile(sessionFile, "utf-8"); + for (const line of content.split("\n")) { + if (!line.trim()) { + continue; + } + try { + const entry = JSON.parse(line); + if (entry.type === "message" && entry.message) { + messages.push(entry.message); + } + } catch { + // skip malformed lines + } + } + } else { + logVerbose("before_reset: no session file available, firing hook with empty messages"); + } + await hookRunner.runBeforeReset( + { sessionFile, messages, reason: params.action }, + { + agentId: params.sessionKey?.split(":")[0] ?? "main", + sessionKey: params.sessionKey, + sessionId: prevEntry?.sessionId, + workspaceDir: params.workspaceDir, + }, + ); + } catch (err: unknown) { + logVerbose(`before_reset hook failed: ${String(err)}`); + } + })(); + } +} + export async function handleCommands(params: HandleCommandsParams): Promise { if (HANDLERS === null) { HANDLERS = [ @@ -79,79 +169,17 @@ export async function handleCommands(params: HandleCommandsParams): Promise 0) { - // Use OriginatingChannel/To if available, otherwise fall back to command channel/from - // oxlint-disable-next-line typescript/no-explicit-any - const channel = params.ctx.OriginatingChannel || (params.command.channel as any); - // For replies, use 'from' (the sender) not 'to' (which might be the bot itself) - const to = params.ctx.OriginatingTo || params.command.from || params.command.to; - - if (channel && to) { - const hookReply = { text: hookEvent.messages.join("\n\n") }; - await routeReply({ - payload: hookReply, - channel: channel, - to: to, - sessionKey: params.sessionKey, - accountId: params.ctx.AccountId, - threadId: params.ctx.MessageThreadId, - cfg: params.cfg, - }); - } - } - - // Fire before_reset plugin hook — extract memories before session history is lost - const hookRunner = getGlobalHookRunner(); - if (hookRunner?.hasHooks("before_reset")) { - const prevEntry = params.previousSessionEntry; - const sessionFile = prevEntry?.sessionFile; - // Fire-and-forget: read old session messages and run hook - void (async () => { - try { - const messages: unknown[] = []; - if (sessionFile) { - const content = await fs.readFile(sessionFile, "utf-8"); - for (const line of content.split("\n")) { - if (!line.trim()) { - continue; - } - try { - const entry = JSON.parse(line); - if (entry.type === "message" && entry.message) { - messages.push(entry.message); - } - } catch { - // skip malformed lines - } - } - } else { - logVerbose("before_reset: no session file available, firing hook with empty messages"); - } - await hookRunner.runBeforeReset( - { sessionFile, messages, reason: commandAction }, - { - agentId: params.sessionKey?.split(":")[0] ?? "main", - sessionKey: params.sessionKey, - sessionId: prevEntry?.sessionId, - workspaceDir: params.workspaceDir, - }, - ); - } catch (err: unknown) { - logVerbose(`before_reset hook failed: ${String(err)}`); - } - })(); - } } const allowTextCommands = shouldHandleTextCommands({ diff --git a/src/auto-reply/reply/commands-types.ts b/src/auto-reply/reply/commands-types.ts index 6ff476b8c20d..4662bf12a22b 100644 --- a/src/auto-reply/reply/commands-types.ts +++ b/src/auto-reply/reply/commands-types.ts @@ -20,6 +20,8 @@ export type CommandContext = { commandBodyNormalized: string; from?: string; to?: string; + /** Internal marker to prevent duplicate reset-hook emission across command pipelines. */ + resetHookTriggered?: boolean; }; export type HandleCommandsParams = { diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 0c4d40ec7eb0..921081921e06 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -890,6 +890,37 @@ describe("handleCommands hooks", () => { expect(spy).toHaveBeenCalledWith(expect.objectContaining({ type: "command", action: "new" })); spy.mockRestore(); }); + + it("triggers hooks for native /new routed to target sessions", async () => { + const cfg = { + commands: { text: true }, + channels: { telegram: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/new", cfg, { + Provider: "telegram", + Surface: "telegram", + CommandSource: "native", + CommandTargetSessionKey: "agent:main:telegram:direct:123", + SessionKey: "telegram:slash:123", + SenderId: "123", + From: "telegram:123", + To: "slash:123", + CommandAuthorized: true, + }); + params.sessionKey = "agent:main:telegram:direct:123"; + const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); + + await handleCommands(params); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "command", + action: "new", + sessionKey: "agent:main:telegram:direct:123", + }), + ); + spy.mockRestore(); + }); }); describe("handleCommands context", () => { diff --git a/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts new file mode 100644 index 000000000000..3129bb61cbb3 --- /dev/null +++ b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts @@ -0,0 +1,251 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MsgContext } from "../templating.js"; + +const mocks = vi.hoisted(() => ({ + resolveReplyDirectives: vi.fn(), + handleInlineActions: vi.fn(), + emitResetCommandHooks: vi.fn(), + initSessionState: vi.fn(), +})); + +vi.mock("../../agents/agent-scope.js", () => ({ + resolveAgentDir: vi.fn(() => "/tmp/agent"), + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), + resolveSessionAgentId: vi.fn(() => "main"), + resolveAgentSkillsFilter: vi.fn(() => undefined), +})); +vi.mock("../../agents/model-selection.js", () => ({ + resolveModelRefFromString: vi.fn(() => null), +})); +vi.mock("../../agents/timeout.js", () => ({ + resolveAgentTimeoutMs: vi.fn(() => 60000), +})); +vi.mock("../../agents/workspace.js", () => ({ + DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/workspace", + ensureAgentWorkspace: vi.fn(async () => ({ dir: "/tmp/workspace" })), +})); +vi.mock("../../channels/model-overrides.js", () => ({ + resolveChannelModelOverride: vi.fn(() => undefined), +})); +vi.mock("../../config/config.js", () => ({ + loadConfig: vi.fn(() => ({})), +})); +vi.mock("../../link-understanding/apply.js", () => ({ + applyLinkUnderstanding: vi.fn(async () => undefined), +})); +vi.mock("../../media-understanding/apply.js", () => ({ + applyMediaUnderstanding: vi.fn(async () => undefined), +})); +vi.mock("../../runtime.js", () => ({ + defaultRuntime: { log: vi.fn() }, +})); +vi.mock("../command-auth.js", () => ({ + resolveCommandAuthorization: vi.fn(() => ({ isAuthorizedSender: true })), +})); +vi.mock("./commands-core.js", () => ({ + emitResetCommandHooks: (...args: unknown[]) => mocks.emitResetCommandHooks(...args), +})); +vi.mock("./directive-handling.js", () => ({ + resolveDefaultModel: vi.fn(() => ({ + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex: new Map(), + })), +})); +vi.mock("./get-reply-directives.js", () => ({ + resolveReplyDirectives: (...args: unknown[]) => mocks.resolveReplyDirectives(...args), +})); +vi.mock("./get-reply-inline-actions.js", () => ({ + handleInlineActions: (...args: unknown[]) => mocks.handleInlineActions(...args), +})); +vi.mock("./get-reply-run.js", () => ({ + runPreparedReply: vi.fn(async () => undefined), +})); +vi.mock("./inbound-context.js", () => ({ + finalizeInboundContext: vi.fn((ctx: unknown) => ctx), +})); +vi.mock("./session-reset-model.js", () => ({ + applyResetModelOverride: vi.fn(async () => undefined), +})); +vi.mock("./session.js", () => ({ + initSessionState: (...args: unknown[]) => mocks.initSessionState(...args), +})); +vi.mock("./stage-sandbox-media.js", () => ({ + stageSandboxMedia: vi.fn(async () => undefined), +})); +vi.mock("./typing.js", () => ({ + createTypingController: vi.fn(() => ({ + onReplyStart: async () => undefined, + startTypingLoop: async () => undefined, + startTypingOnText: async () => undefined, + refreshTypingTtl: () => undefined, + isActive: () => false, + markRunComplete: () => undefined, + markDispatchIdle: () => undefined, + cleanup: () => undefined, + })), +})); + +const { getReplyFromConfig } = await import("./get-reply.js"); + +function buildNativeResetContext(): MsgContext { + return { + Provider: "telegram", + Surface: "telegram", + ChatType: "direct", + Body: "/new", + RawBody: "/new", + CommandBody: "/new", + CommandSource: "native", + CommandAuthorized: true, + SessionKey: "telegram:slash:123", + CommandTargetSessionKey: "agent:main:telegram:direct:123", + From: "telegram:123", + To: "slash:123", + }; +} + +describe("getReplyFromConfig reset-hook fallback", () => { + beforeEach(() => { + mocks.resolveReplyDirectives.mockReset(); + mocks.handleInlineActions.mockReset(); + mocks.emitResetCommandHooks.mockReset(); + mocks.initSessionState.mockReset(); + + mocks.initSessionState.mockResolvedValue({ + sessionCtx: buildNativeResetContext(), + sessionEntry: {}, + previousSessionEntry: {}, + sessionStore: {}, + sessionKey: "agent:main:telegram:direct:123", + sessionId: "session-1", + isNewSession: true, + resetTriggered: true, + systemSent: false, + abortedLastRun: false, + storePath: "/tmp/sessions.json", + sessionScope: "per-sender", + groupResolution: undefined, + isGroup: false, + triggerBodyNormalized: "/new", + bodyStripped: "", + }); + + mocks.resolveReplyDirectives.mockResolvedValue({ + kind: "continue", + result: { + commandSource: "/new", + command: { + surface: "telegram", + channel: "telegram", + channelId: "telegram", + ownerList: [], + senderIsOwner: true, + isAuthorizedSender: true, + senderId: "123", + abortKey: "telegram:slash:123", + rawBodyNormalized: "/new", + commandBodyNormalized: "/new", + from: "telegram:123", + to: "slash:123", + resetHookTriggered: false, + }, + allowTextCommands: true, + skillCommands: [], + directives: {}, + cleanedBody: "/new", + elevatedEnabled: false, + elevatedAllowed: false, + elevatedFailures: [], + defaultActivation: "always", + resolvedThinkLevel: undefined, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolvedElevatedLevel: "off", + execOverrides: undefined, + blockStreamingEnabled: false, + blockReplyChunking: undefined, + resolvedBlockStreamingBreak: undefined, + provider: "openai", + model: "gpt-4o-mini", + modelState: { + resolveDefaultThinkingLevel: async () => undefined, + }, + contextTokens: 0, + inlineStatusRequested: false, + directiveAck: undefined, + perMessageQueueMode: undefined, + perMessageQueueOptions: undefined, + }, + }); + }); + + it("emits reset hooks when inline actions return early without marking resetHookTriggered", async () => { + mocks.handleInlineActions.mockResolvedValue({ kind: "reply", reply: undefined }); + + await getReplyFromConfig(buildNativeResetContext(), undefined, {}); + + expect(mocks.emitResetCommandHooks).toHaveBeenCalledTimes(1); + expect(mocks.emitResetCommandHooks).toHaveBeenCalledWith( + expect.objectContaining({ + action: "new", + sessionKey: "agent:main:telegram:direct:123", + }), + ); + }); + + it("does not emit fallback hooks when resetHookTriggered is already set", async () => { + mocks.handleInlineActions.mockResolvedValue({ kind: "reply", reply: undefined }); + mocks.resolveReplyDirectives.mockResolvedValue({ + kind: "continue", + result: { + commandSource: "/new", + command: { + surface: "telegram", + channel: "telegram", + channelId: "telegram", + ownerList: [], + senderIsOwner: true, + isAuthorizedSender: true, + senderId: "123", + abortKey: "telegram:slash:123", + rawBodyNormalized: "/new", + commandBodyNormalized: "/new", + from: "telegram:123", + to: "slash:123", + resetHookTriggered: true, + }, + allowTextCommands: true, + skillCommands: [], + directives: {}, + cleanedBody: "/new", + elevatedEnabled: false, + elevatedAllowed: false, + elevatedFailures: [], + defaultActivation: "always", + resolvedThinkLevel: undefined, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolvedElevatedLevel: "off", + execOverrides: undefined, + blockStreamingEnabled: false, + blockReplyChunking: undefined, + resolvedBlockStreamingBreak: undefined, + provider: "openai", + model: "gpt-4o-mini", + modelState: { + resolveDefaultThinkingLevel: async () => undefined, + }, + contextTokens: 0, + inlineStatusRequested: false, + directiveAck: undefined, + perMessageQueueMode: undefined, + perMessageQueueOptions: undefined, + }, + }); + + await getReplyFromConfig(buildNativeResetContext(), undefined, {}); + + expect(mocks.emitResetCommandHooks).not.toHaveBeenCalled(); + }); +}); diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index bca4cb3ce8f9..5c4edd35ac1e 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -16,6 +16,7 @@ import { resolveCommandAuthorization } from "../command-auth.js"; import type { MsgContext } from "../templating.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; +import { emitResetCommandHooks, type ResetCommandAction } from "./commands-core.js"; import { resolveDefaultModel } from "./directive-handling.js"; import { resolveReplyDirectives } from "./get-reply-directives.js"; import { handleInlineActions } from "./get-reply-inline-actions.js"; @@ -272,6 +273,27 @@ export async function getReplyFromConfig( provider = resolvedProvider; model = resolvedModel; + const maybeEmitMissingResetHooks = async () => { + if (!resetTriggered || !command.isAuthorizedSender || command.resetHookTriggered) { + return; + } + const resetMatch = command.commandBodyNormalized.match(/^\/(new|reset)(?:\s|$)/); + if (!resetMatch) { + return; + } + const action: ResetCommandAction = resetMatch[1] === "reset" ? "reset" : "new"; + await emitResetCommandHooks({ + action, + ctx, + cfg, + command, + sessionKey, + sessionEntry, + previousSessionEntry, + workspaceDir, + }); + }; + const inlineActionResult = await handleInlineActions({ ctx, sessionCtx, @@ -311,8 +333,10 @@ export async function getReplyFromConfig( skillFilter: mergedSkillFilter, }); if (inlineActionResult.kind === "reply") { + await maybeEmitMissingResetHooks(); return inlineActionResult.reply; } + await maybeEmitMissingResetHooks(); directives = inlineActionResult.directives; abortedLastRun = inlineActionResult.abortedLastRun ?? abortedLastRun; From 0365125c21a06d4827d0bc55f942a8cb22117831 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:27:33 +0000 Subject: [PATCH 169/408] fix: add changelog for reset hook fallback coverage (#25459) (thanks @chilu18) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af9185f763a8..7bd95ed2f5c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18. - Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi. - Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr. - Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728. From b5787e4abba0dcc6baf09051099f6773c1679ec1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:37:13 +0000 Subject: [PATCH 170/408] fix(sandbox): harden bind validation for symlink missing-leaf paths --- CHANGELOG.md | 1 + .../sandbox/validate-sandbox-security.test.ts | 40 +++++++++++- .../sandbox/validate-sandbox-security.ts | 63 ++++++++++++------- 3 files changed, 81 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bd95ed2f5c1..fb88e0dbb7e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. - Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase. - CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. +- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. ## 2026.2.23 diff --git a/src/agents/sandbox/validate-sandbox-security.test.ts b/src/agents/sandbox/validate-sandbox-security.test.ts index fae66cc79249..22a5be14d5da 100644 --- a/src/agents/sandbox/validate-sandbox-security.test.ts +++ b/src/agents/sandbox/validate-sandbox-security.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, symlinkSync } from "node:fs"; +import { mkdirSync, mkdtempSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -117,6 +117,44 @@ describe("validateBindMounts", () => { expect(run).toThrow(/blocked path/); }); + it("blocks symlink-parent escapes with non-existent leaf outside allowed roots", () => { + if (process.platform === "win32") { + // Windows source paths (e.g. C:\\...) are intentionally rejected as non-POSIX. + return; + } + const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-")); + const workspace = join(dir, "workspace"); + const outside = join(dir, "outside"); + mkdirSync(workspace, { recursive: true }); + mkdirSync(outside, { recursive: true }); + const link = join(workspace, "alias-out"); + symlinkSync(outside, link); + const missingLeaf = join(link, "not-yet-created"); + expect(() => + validateBindMounts([`${missingLeaf}:/mnt/data:ro`], { + allowedSourceRoots: [workspace], + }), + ).toThrow(/outside allowed roots/); + }); + + it("blocks symlink-parent escapes into blocked paths when leaf does not exist", () => { + if (process.platform === "win32") { + // Windows source paths (e.g. C:\\...) are intentionally rejected as non-POSIX. + return; + } + const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-")); + const workspace = join(dir, "workspace"); + mkdirSync(workspace, { recursive: true }); + const link = join(workspace, "run-link"); + symlinkSync("/var/run", link); + const missingLeaf = join(link, "openclaw-not-created"); + expect(() => + validateBindMounts([`${missingLeaf}:/mnt/run:ro`], { + allowedSourceRoots: [workspace], + }), + ).toThrow(/blocked path/); + }); + it("rejects non-absolute source paths (relative or named volumes)", () => { const cases = ["../etc/passwd:/mnt/passwd", "etc/passwd:/mnt/passwd", "myvol:/mnt"] as const; for (const source of cases) { diff --git a/src/agents/sandbox/validate-sandbox-security.ts b/src/agents/sandbox/validate-sandbox-security.ts index a14fd50d0368..44fe9f7ba0da 100644 --- a/src/agents/sandbox/validate-sandbox-security.ts +++ b/src/agents/sandbox/validate-sandbox-security.ts @@ -119,18 +119,38 @@ export function getBlockedReasonForSourcePath(sourceNormalized: string): Blocked return null; } -function tryRealpathAbsolute(path: string): string { - if (!path.startsWith("/")) { - return path; +function resolvePathViaExistingAncestor(sourcePath: string): string { + if (!sourcePath.startsWith("/")) { + return sourcePath; } - if (!existsSync(path)) { - return path; + + const normalized = normalizeHostPath(sourcePath); + let current = normalized; + const missingSegments: string[] = []; + + // Resolve through the deepest existing ancestor so symlink parents are honored + // even when the final source leaf does not exist yet. + while (current !== "/" && !existsSync(current)) { + missingSegments.unshift(posix.basename(current)); + const parent = posix.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + if (!existsSync(current)) { + return normalized; } + try { - // Use native when available (keeps platform semantics); normalize for prefix checks. - return normalizeHostPath(realpathSync.native(path)); + const resolvedAncestor = normalizeHostPath(realpathSync.native(current)); + if (missingSegments.length === 0) { + return resolvedAncestor; + } + return normalizeHostPath(posix.join(resolvedAncestor, ...missingSegments)); } catch { - return path; + return normalized; } } @@ -145,7 +165,7 @@ function normalizeAllowedRoots(roots: string[] | undefined): string[] { const expanded = new Set(); for (const root of normalized) { expanded.add(root); - const real = tryRealpathAbsolute(root); + const real = resolvePathViaExistingAncestor(root); if (real !== root) { expanded.add(real); } @@ -227,7 +247,8 @@ function formatBindBlockedError(params: { bind: string; reason: BlockedBindReaso /** * Validate bind mounts — throws if any source path is dangerous. - * Includes a symlink/realpath pass when the source path exists. + * Includes a symlink/realpath pass via existing ancestors so non-existent leaf + * paths cannot bypass source-root and blocked-path checks. */ export function validateBindMounts( binds: string[] | undefined, @@ -268,18 +289,16 @@ export function validateBindMounts( } } - // Symlink escape hardening: resolve existing absolute paths and re-check. - const sourceReal = tryRealpathAbsolute(sourceNormalized); - if (sourceReal !== sourceNormalized) { - const reason = getBlockedReasonForSourcePath(sourceReal); - if (reason) { - throw formatBindBlockedError({ bind, reason }); - } - if (!options?.allowSourcesOutsideAllowedRoots) { - const allowedReason = getOutsideAllowedRootsReason(sourceReal, allowedRoots); - if (allowedReason) { - throw formatBindBlockedError({ bind, reason: allowedReason }); - } + // Symlink escape hardening: resolve through existing ancestors and re-check. + const sourceCanonical = resolvePathViaExistingAncestor(sourceNormalized); + const reason = getBlockedReasonForSourcePath(sourceCanonical); + if (reason) { + throw formatBindBlockedError({ bind, reason }); + } + if (!options?.allowSourcesOutsideAllowedRoots) { + const allowedReason = getOutsideAllowedRootsReason(sourceCanonical, allowedRoots); + if (allowedReason) { + throw formatBindBlockedError({ bind, reason: allowedReason }); } } } From 2c4ebf77f35f466fb5cfb09e06a9532953f0b03d Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Tue, 24 Feb 2026 11:12:12 -0300 Subject: [PATCH 171/408] fix(config): coerce numeric meta.lastTouchedAt to ISO string --- .../config.meta-timestamp-coercion.test.ts | 70 +++++++++++++++++++ src/config/zod-schema.ts | 16 ++++- 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/config/config.meta-timestamp-coercion.test.ts diff --git a/src/config/config.meta-timestamp-coercion.test.ts b/src/config/config.meta-timestamp-coercion.test.ts new file mode 100644 index 000000000000..d87b16b451e9 --- /dev/null +++ b/src/config/config.meta-timestamp-coercion.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; + +describe("meta.lastTouchedAt numeric timestamp coercion", () => { + it("accepts a numeric Unix timestamp and coerces it to an ISO string", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const numericTimestamp = 1770394758161; + const res = validateConfigObject({ + meta: { + lastTouchedAt: numericTimestamp, + }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(typeof res.config.meta?.lastTouchedAt).toBe("string"); + expect(res.config.meta?.lastTouchedAt).toBe(new Date(numericTimestamp).toISOString()); + } + }); + + it("still accepts a string ISO timestamp unchanged", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const isoTimestamp = "2026-02-07T01:39:18.161Z"; + const res = validateConfigObject({ + meta: { + lastTouchedAt: isoTimestamp, + }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.meta?.lastTouchedAt).toBe(isoTimestamp); + } + }); + + it("rejects out-of-range numeric timestamps without throwing", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + meta: { + lastTouchedAt: 1e20, + }, + }); + expect(res.ok).toBe(false); + }); + + it("passes non-date strings through unchanged (backwards-compatible)", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + meta: { + lastTouchedAt: "not-a-date", + }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.meta?.lastTouchedAt).toBe("not-a-date"); + } + }); + + it("accepts meta with only lastTouchedVersion (no lastTouchedAt)", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + meta: { + lastTouchedVersion: "2026.2.6", + }, + }); + expect(res.ok).toBe(true); + }); +}); diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 70b528f904c0..dd6b1b1c1d0d 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -129,7 +129,21 @@ export const OpenClawSchema = z meta: z .object({ lastTouchedVersion: z.string().optional(), - lastTouchedAt: z.string().optional(), + // Accept any string unchanged (backwards-compatible) and coerce numeric Unix + // timestamps to ISO strings (agent file edits may write Date.now()). + lastTouchedAt: z + .union([ + z.string(), + z.number().transform((n, ctx) => { + const d = new Date(n); + if (Number.isNaN(d.getTime())) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid timestamp" }); + return z.NEVER; + } + return d.toISOString(); + }), + ]) + .optional(), }) .strict() .optional(), From d2c031de84f7b292ddfb7736653132fdea468f45 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:38:59 +0000 Subject: [PATCH 172/408] fix: add changelog for meta timestamp coercion (#25491) (thanks @mcaxtr) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb88e0dbb7e0..a88e4dbe1e7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr. - Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18. - Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi. - Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr. From 23b9daee6fb57fa657b44ab9c831e824377b464b Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Tue, 24 Feb 2026 10:23:33 -0300 Subject: [PATCH 173/408] fix(doctor): improve sandbox warning when Docker unavailable --- src/commands/doctor-sandbox.ts | 11 +- ...rns-sandbox-enabled-without-docker.test.ts | 129 ++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 src/commands/doctor-sandbox.warns-sandbox-enabled-without-docker.test.ts diff --git a/src/commands/doctor-sandbox.ts b/src/commands/doctor-sandbox.ts index aa08fb867326..90790e90737a 100644 --- a/src/commands/doctor-sandbox.ts +++ b/src/commands/doctor-sandbox.ts @@ -188,7 +188,16 @@ export async function maybeRepairSandboxImages( const dockerAvailable = await isDockerAvailable(); if (!dockerAvailable) { - note("Docker not available; skipping sandbox image checks.", "Sandbox"); + const lines = [ + `Sandbox mode is enabled (mode: "${mode}") but Docker is not available.`, + "Docker is required for sandbox mode to function.", + "Isolated sessions (cron jobs, sub-agents) will fail without Docker.", + "", + "Options:", + "- Install Docker and restart the gateway", + "- Disable sandbox mode: openclaw config set agents.defaults.sandbox.mode off", + ]; + note(lines.join("\n"), "Sandbox"); return cfg; } diff --git a/src/commands/doctor-sandbox.warns-sandbox-enabled-without-docker.test.ts b/src/commands/doctor-sandbox.warns-sandbox-enabled-without-docker.test.ts new file mode 100644 index 000000000000..106066c511a2 --- /dev/null +++ b/src/commands/doctor-sandbox.warns-sandbox-enabled-without-docker.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; + +const runExec = vi.fn(); +const note = vi.fn(); + +vi.mock("../process/exec.js", () => ({ + runExec, + runCommandWithTimeout: vi.fn(), +})); + +vi.mock("../terminal/note.js", () => ({ + note, +})); + +describe("maybeRepairSandboxImages", () => { + const mockRuntime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const mockPrompter: DoctorPrompter = { + confirmSkipInNonInteractive: vi.fn().mockResolvedValue(false), + } as unknown as DoctorPrompter; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("warns when sandbox mode is enabled but Docker is not available", async () => { + // Simulate Docker not available (command fails) + runExec.mockRejectedValue(new Error("Docker not installed")); + + const config: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "non-main", + }, + }, + }, + }; + + const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js"); + await maybeRepairSandboxImages(config, mockRuntime, mockPrompter); + + // The warning should clearly indicate sandbox is enabled but won't work + expect(note).toHaveBeenCalled(); + const noteCall = note.mock.calls[0]; + const message = noteCall[0] as string; + + // The message should warn that sandbox mode won't function, not just "skipping checks" + expect(message).toMatch(/sandbox.*mode.*enabled|sandbox.*won.*work|docker.*required/i); + // Should NOT just say "skipping sandbox image checks" - that's too mild + expect(message).not.toBe("Docker not available; skipping sandbox image checks."); + }); + + it("warns when sandbox mode is 'all' but Docker is not available", async () => { + runExec.mockRejectedValue(new Error("Docker not installed")); + + const config: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "all", + }, + }, + }, + }; + + const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js"); + await maybeRepairSandboxImages(config, mockRuntime, mockPrompter); + + expect(note).toHaveBeenCalled(); + const noteCall = note.mock.calls[0]; + const message = noteCall[0] as string; + + // Should warn about the impact on sandbox functionality + expect(message).toMatch(/sandbox|docker/i); + }); + + it("does not warn when sandbox mode is off", async () => { + runExec.mockRejectedValue(new Error("Docker not installed")); + + const config: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "off", + }, + }, + }, + }; + + const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js"); + await maybeRepairSandboxImages(config, mockRuntime, mockPrompter); + + // No warning needed when sandbox is off + expect(note).not.toHaveBeenCalled(); + }); + + it("does not warn when Docker is available", async () => { + // Simulate Docker available + runExec.mockResolvedValue({ stdout: "24.0.0", stderr: "" }); + + const config: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "non-main", + }, + }, + }, + }; + + const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js"); + await maybeRepairSandboxImages(config, mockRuntime, mockPrompter); + + // May have other notes about images, but not the Docker unavailable warning + const dockerUnavailableWarning = note.mock.calls.find( + (call) => + typeof call[0] === "string" && call[0].toLowerCase().includes("docker not available"), + ); + expect(dockerUnavailableWarning).toBeUndefined(); + }); +}); From b511a38fc82a960989ca889198816318eae6892e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:39:55 +0000 Subject: [PATCH 174/408] fix: add changelog for doctor sandbox docker warning (#25438) (thanks @mcaxtr) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a88e4dbe1e7b..daf4722ce97e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. - Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr. - Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18. - Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi. From aa2826b5b16e4b0e2d633851b5e41867a5249fb1 Mon Sep 17 00:00:00 2001 From: Elarwei Date: Tue, 24 Feb 2026 21:21:14 +0800 Subject: [PATCH 175/408] fix(usage): parse Kimi K2 cached_tokens from prompt_tokens_details Kimi K2 models use automatic prefix caching and return cache stats in a nested field: usage.prompt_tokens_details.cached_tokens This fixes issue #7073 where cacheRead was showing 0 for K2.5 users. Also adds cached_tokens (top-level) for moonshot-v1 explicit caching API. Closes #7073 --- src/agents/usage.test.ts | 34 ++++++++++++++++++++++++++++++++++ src/agents/usage.ts | 12 +++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/agents/usage.test.ts b/src/agents/usage.test.ts index 8c12c395d456..ade9e151d8da 100644 --- a/src/agents/usage.test.ts +++ b/src/agents/usage.test.ts @@ -54,6 +54,40 @@ describe("normalizeUsage", () => { }); }); + it("handles Moonshot/Kimi cached_tokens field", () => { + // Moonshot v1 returns cached_tokens instead of cache_read_input_tokens + const usage = normalizeUsage({ + prompt_tokens: 30, + completion_tokens: 9, + total_tokens: 39, + cached_tokens: 19, + }); + expect(usage).toEqual({ + input: 30, + output: 9, + cacheRead: 19, + cacheWrite: undefined, + total: 39, + }); + }); + + it("handles Kimi K2 prompt_tokens_details.cached_tokens field", () => { + // Kimi K2 uses automatic prefix caching and returns cached_tokens in prompt_tokens_details + const usage = normalizeUsage({ + prompt_tokens: 1113, + completion_tokens: 5, + total_tokens: 1118, + prompt_tokens_details: { cached_tokens: 1024 }, + }); + expect(usage).toEqual({ + input: 1113, + output: 5, + cacheRead: 1024, + cacheWrite: undefined, + total: 1118, + }); + }); + it("returns undefined when no valid fields are provided", () => { const usage = normalizeUsage(null); expect(usage).toBeUndefined(); diff --git a/src/agents/usage.ts b/src/agents/usage.ts index eaf48d5f1ac7..be23df971166 100644 --- a/src/agents/usage.ts +++ b/src/agents/usage.ts @@ -15,6 +15,10 @@ export type UsageLike = { completion_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; + // Moonshot/Kimi uses cached_tokens for cache read count (explicit caching API). + cached_tokens?: number; + // Kimi K2 uses prompt_tokens_details.cached_tokens for automatic prefix caching. + prompt_tokens_details?: { cached_tokens?: number }; // Some agents/logs emit alternate naming. totalTokens?: number; total_tokens?: number; @@ -64,7 +68,13 @@ export function normalizeUsage(raw?: UsageLike | null): NormalizedUsage | undefi raw.completionTokens ?? raw.completion_tokens, ); - const cacheRead = asFiniteNumber(raw.cacheRead ?? raw.cache_read ?? raw.cache_read_input_tokens); + const cacheRead = asFiniteNumber( + raw.cacheRead ?? + raw.cache_read ?? + raw.cache_read_input_tokens ?? + raw.cached_tokens ?? + raw.prompt_tokens_details?.cached_tokens, + ); const cacheWrite = asFiniteNumber( raw.cacheWrite ?? raw.cache_write ?? raw.cache_creation_input_tokens, ); From 760671e31ccf84d340d7bf3ea89d2a35fe278ca5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:40:41 +0000 Subject: [PATCH 176/408] fix: add changelog for kimi cache usage parsing (#25436) (thanks @Elarwei001) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index daf4722ce97e..ce4b669b0a09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. - Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. - Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr. - Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18. From 31b1b20b3c30c5b22b5ee499165a8605e2242198 Mon Sep 17 00:00:00 2001 From: zzzz Date: Tue, 24 Feb 2026 22:12:52 +0800 Subject: [PATCH 177/408] docs: add WeChat community plugin listing Add @icesword760/openclaw-wechat to the community plugins page. This plugin connects OpenClaw to WeChat personal accounts via WeChatPadPro (iPad protocol) with support for text, image, and file exchange. Co-authored-by: Cursor --- docs/plugins/community.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/plugins/community.md b/docs/plugins/community.md index c135381676ce..94c6ddbe00d8 100644 --- a/docs/plugins/community.md +++ b/docs/plugins/community.md @@ -42,3 +42,10 @@ Use this format when adding entries: npm: `@scope/package` repo: `https://github.com/org/repo` install: `openclaw plugins install @scope/package` + +## Listed plugins + +- **WeChat** — Connect OpenClaw to WeChat personal accounts via WeChatPadPro (iPad protocol). Supports text, image, and file exchange with keyword-triggered conversations. + npm: `@icesword760/openclaw-wechat` + repo: `https://github.com/icesword0760/openclaw-wechat` + install: `openclaw plugins install @icesword760/openclaw-wechat` From 8db7ca8c0268fdc2b6458f12a4ca5b1a70e10cd5 Mon Sep 17 00:00:00 2001 From: Leakim Date: Tue, 24 Feb 2026 13:15:38 +0000 Subject: [PATCH 178/408] fix: prevent synthetic toolResult for aborted/errored assistant messages When an assistant message with toolCalls has stopReason 'aborted' or 'error', the guard should not add those tool call IDs to the pending map. Creating synthetic tool results for incomplete/aborted tool calls causes API 400 errors: 'unexpected tool_use_id found in tool_result blocks' This aligns the WRITE path (session-tool-result-guard.ts) with the READ path (session-transcript-repair.ts) which already skips aborted messages. Fixes: orphaned tool_result causing session corruption Tests added: - does NOT create synthetic toolResult for aborted assistant messages - does NOT create synthetic toolResult for errored assistant messages --- src/agents/session-tool-result-guard.test.ts | 59 ++++++++++++++++++++ src/agents/session-tool-result-guard.ts | 9 ++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/agents/session-tool-result-guard.test.ts b/src/agents/session-tool-result-guard.test.ts index 7b6566066467..105b587cf9d8 100644 --- a/src/agents/session-tool-result-guard.test.ts +++ b/src/agents/session-tool-result-guard.test.ts @@ -357,4 +357,63 @@ describe("installSessionToolResultGuard", () => { sourceTool: "sessions_send", }); }); + + // Regression test for orphaned tool_result bug + // See: https://github.com/clawdbot/clawdbot/issues/XXXX + // When an assistant message with toolCalls is aborted, no synthetic toolResult + // should be created. Creating synthetic results for aborted/incomplete tool calls + // causes API 400 errors: "unexpected tool_use_id found in tool_result blocks" + it("does NOT create synthetic toolResult for aborted assistant messages with toolCalls", () => { + const sm = SessionManager.inMemory(); + installSessionToolResultGuard(sm); + + // Aborted assistant message with incomplete toolCall + sm.appendMessage( + asAppendMessage({ + role: "assistant", + content: [{ type: "toolCall", id: "call_aborted", name: "read", arguments: {} }], + stopReason: "aborted", + }), + ); + + // Next message triggers flush of pending tool calls + sm.appendMessage( + asAppendMessage({ + role: "user", + content: "are you stuck?", + timestamp: Date.now(), + }), + ); + + // Should only have assistant + user, NO synthetic toolResult + const messages = getPersistedMessages(sm); + const roles = messages.map((m) => m.role); + expect(roles).toEqual(["assistant", "user"]); + expect(roles).not.toContain("toolResult"); + }); + + it("does NOT create synthetic toolResult for errored assistant messages with toolCalls", () => { + const sm = SessionManager.inMemory(); + const guard = installSessionToolResultGuard(sm); + + // Error assistant message with incomplete toolCall + sm.appendMessage( + asAppendMessage({ + role: "assistant", + content: [{ type: "toolCall", id: "call_error", name: "exec", arguments: {} }], + stopReason: "error", + }), + ); + + // Explicit flush should NOT create synthetic result for errored messages + guard.flushPendingToolResults(); + + const messages = getPersistedMessages(sm); + const toolResults = messages.filter((m) => m.role === "toolResult"); + // No synthetic toolResults should exist for the errored call + const syntheticForError = toolResults.filter( + (m) => (m as { toolCallId?: string }).toolCallId === "call_error", + ); + expect(syntheticForError).toHaveLength(0); + }); }); diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index 689bb816c1e8..dba618a31035 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -166,8 +166,15 @@ export function installSessionToolResultGuard( return originalAppend(persisted as never); } + // Skip tool call extraction for aborted/errored assistant messages. + // When stopReason is "error" or "aborted", the tool_use blocks may be incomplete + // and should not have synthetic tool_results created. Creating synthetic results + // for incomplete tool calls causes API 400 errors: + // "unexpected tool_use_id found in tool_result blocks" + // This matches the behavior in repairToolUseResultPairing (session-transcript-repair.ts) + const stopReason = (nextMessage as { stopReason?: string }).stopReason; const toolCalls = - nextRole === "assistant" + nextRole === "assistant" && stopReason !== "aborted" && stopReason !== "error" ? extractToolCallsFromAssistant(nextMessage as Extract) : []; From 6da03eabe254af2f336f732fee7557f1f78f1053 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:41:46 +0000 Subject: [PATCH 179/408] fix: add changelog and clean regression comment for tool-result guard (#25429) (thanks @mikaeldiakhate-cell) --- CHANGELOG.md | 1 + src/agents/session-tool-result-guard.test.ts | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce4b669b0a09..69cdf788c0a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. - Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. - Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. - Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr. diff --git a/src/agents/session-tool-result-guard.test.ts b/src/agents/session-tool-result-guard.test.ts index 105b587cf9d8..7df8b8d48df3 100644 --- a/src/agents/session-tool-result-guard.test.ts +++ b/src/agents/session-tool-result-guard.test.ts @@ -358,11 +358,9 @@ describe("installSessionToolResultGuard", () => { }); }); - // Regression test for orphaned tool_result bug - // See: https://github.com/clawdbot/clawdbot/issues/XXXX // When an assistant message with toolCalls is aborted, no synthetic toolResult // should be created. Creating synthetic results for aborted/incomplete tool calls - // causes API 400 errors: "unexpected tool_use_id found in tool_result blocks" + // causes API 400 errors: "unexpected tool_use_id found in tool_result blocks". it("does NOT create synthetic toolResult for aborted assistant messages with toolCalls", () => { const sm = SessionManager.inMemory(); installSessionToolResultGuard(sm); From 9168f2147f742ef573f25b155542aa1035d88b56 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:47:39 +0000 Subject: [PATCH 180/408] test: add case-insensitive stop abort assertions --- src/auto-reply/reply/abort.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index b35937a6003e..e8386f2fa414 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -179,8 +179,12 @@ describe("abort detection", () => { it("isAbortRequestText aligns abort command semantics", () => { expect(isAbortRequestText("/stop")).toBe(true); + expect(isAbortRequestText("/STOP")).toBe(true); expect(isAbortRequestText("/stop!!!")).toBe(true); + expect(isAbortRequestText("/Stop!!!")).toBe(true); expect(isAbortRequestText("stop")).toBe(true); + expect(isAbortRequestText("Stop")).toBe(true); + expect(isAbortRequestText("STOP")).toBe(true); expect(isAbortRequestText("stop action")).toBe(true); expect(isAbortRequestText("stop openclaw!!!")).toBe(true); expect(isAbortRequestText("やめて")).toBe(true); @@ -190,6 +194,7 @@ describe("abort detection", () => { expect(isAbortRequestText("pare")).toBe(true); expect(isAbortRequestText(" توقف ")).toBe(true); expect(isAbortRequestText("/stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true); + expect(isAbortRequestText("/Stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true); expect(isAbortRequestText("/status")).toBe(false); expect(isAbortRequestText("do not do that")).toBe(false); From c3680c227798263fea3a8178ee53d9d388e19d94 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:47:39 +0000 Subject: [PATCH 181/408] docs(changelog): credit reporter for sandbox bind-path fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69cdf788c0a0..433ca60802ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ Docs: https://docs.openclaw.ai - macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. - Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase. - CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. -- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. +- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey. ## 2026.2.23 From 370d115549c0dadace0902775eea0d5094aedfdc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:47:22 +0000 Subject: [PATCH 182/408] fix: enforce workspaceOnly for native prompt image autoload --- CHANGELOG.md | 1 + SECURITY.md | 2 +- docs/gateway/security/index.md | 2 +- src/agents/pi-embedded-runner/run/attempt.ts | 6 +- .../pi-embedded-runner/run/images.test.ts | 73 +++++++++++++++++++ src/agents/pi-embedded-runner/run/images.ts | 12 +++ 6 files changed, 93 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 433ca60802ec..b100a2808180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. - Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. - Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. - Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. diff --git a/SECURITY.md b/SECURITY.md index 378eceaff914..fe6daa332cae 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -168,7 +168,7 @@ For threat model + hardening guidance (including `openclaw security audit --deep ### Tool filesystem hardening - `tools.exec.applyPatch.workspaceOnly: true` (recommended): keeps `apply_patch` writes/deletes within the configured workspace directory. -- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths to the workspace directory. +- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths and native prompt image auto-load paths to the workspace directory. - Avoid setting `tools.exec.applyPatch.workspaceOnly: false` unless you fully trust who can trigger tool execution. ### Web Interface Safety diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 330555d2ddff..c0d642b0e55e 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -833,7 +833,7 @@ We may add a single `readOnlyMode` flag later to simplify this configuration. Additional hardening options: - `tools.exec.applyPatch.workspaceOnly: true` (default): ensures `apply_patch` cannot write/delete outside the workspace directory even when sandboxing is off. Set to `false` only if you intentionally want `apply_patch` to touch files outside the workspace. -- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths to the workspace directory (useful if you allow absolute paths today and want a single guardrail). +- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths and native prompt image auto-load paths to the workspace directory (useful if you allow absolute paths today and want a single guardrail). ### 5) Secure baseline (copy/paste) diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 9406afae943b..e05a21a5776a 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -28,7 +28,7 @@ import { resolveUserPath } from "../../../utils.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js"; import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; import { resolveOpenClawAgentDir } from "../../agent-paths.js"; -import { resolveSessionAgentIds } from "../../agent-scope.js"; +import { resolveAgentConfig, resolveSessionAgentIds } from "../../agent-scope.js"; import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js"; import { createCacheTrace } from "../../cache-trace.js"; @@ -363,6 +363,9 @@ export async function runEmbeddedAttempt( config: params.config, agentId: params.agentId, }); + const effectiveFsWorkspaceOnly = + (resolveAgentConfig(params.config ?? {}, sessionAgentId)?.tools?.fs?.workspaceOnly ?? + params.config?.tools?.fs?.workspaceOnly) === true; // Check if the model supports native image input const modelHasVision = params.model.input?.includes("image") ?? false; const toolsRaw = params.disableTools @@ -1087,6 +1090,7 @@ export async function runEmbeddedAttempt( historyMessages: activeSession.messages, maxBytes: MAX_IMAGE_BYTES, maxDimensionPx: resolveImageSanitizationLimits(params.config).maxDimensionPx, + workspaceOnly: effectiveFsWorkspaceOnly, // Enforce sandbox path restrictions when sandbox is enabled sandbox: sandbox?.enabled && sandbox?.fsBridge diff --git a/src/agents/pi-embedded-runner/run/images.test.ts b/src/agents/pi-embedded-runner/run/images.test.ts index d19ae3bd8998..f9cb846da40e 100644 --- a/src/agents/pi-embedded-runner/run/images.test.ts +++ b/src/agents/pi-embedded-runner/run/images.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js"; +import { createUnsafeMountedSandbox } from "../../test-helpers/unsafe-mounted-sandbox.js"; import { detectAndLoadPromptImages, detectImageReferences, @@ -275,4 +276,76 @@ describe("detectAndLoadPromptImages", () => { expect(result.images).toHaveLength(0); expect(result.historyImagesByIndex.size).toBe(0); }); + + it("blocks prompt image refs outside workspace when sandbox workspaceOnly is enabled", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-image-sandbox-")); + const sandboxRoot = path.join(stateDir, "sandbox"); + const agentRoot = path.join(stateDir, "agent"); + await fs.mkdir(sandboxRoot, { recursive: true }); + await fs.mkdir(agentRoot, { recursive: true }); + const pngB64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; + await fs.writeFile(path.join(agentRoot, "secret.png"), Buffer.from(pngB64, "base64")); + const sandbox = createUnsafeMountedSandbox({ sandboxRoot, agentRoot }); + const bridge = sandbox.fsBridge; + if (!bridge) { + throw new Error("sandbox fs bridge missing"); + } + + try { + const result = await detectAndLoadPromptImages({ + prompt: "Inspect /agent/secret.png", + workspaceDir: sandboxRoot, + model: { input: ["text", "image"] }, + workspaceOnly: true, + sandbox: { root: sandbox.workspaceDir, bridge }, + }); + + expect(result.detectedRefs).toHaveLength(1); + expect(result.loadedCount).toBe(0); + expect(result.skippedCount).toBe(1); + expect(result.images).toHaveLength(0); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("blocks history image refs outside workspace when sandbox workspaceOnly is enabled", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-image-sandbox-")); + const sandboxRoot = path.join(stateDir, "sandbox"); + const agentRoot = path.join(stateDir, "agent"); + await fs.mkdir(sandboxRoot, { recursive: true }); + await fs.mkdir(agentRoot, { recursive: true }); + const pngB64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; + await fs.writeFile(path.join(agentRoot, "secret.png"), Buffer.from(pngB64, "base64")); + const sandbox = createUnsafeMountedSandbox({ sandboxRoot, agentRoot }); + const bridge = sandbox.fsBridge; + if (!bridge) { + throw new Error("sandbox fs bridge missing"); + } + + try { + const result = await detectAndLoadPromptImages({ + prompt: "No inline image in this turn.", + workspaceDir: sandboxRoot, + model: { input: ["text", "image"] }, + workspaceOnly: true, + historyMessages: [ + { + role: "user", + content: [{ type: "text", text: "Previous image /agent/secret.png" }], + }, + ], + sandbox: { root: sandbox.workspaceDir, bridge }, + }); + + expect(result.detectedRefs).toHaveLength(1); + expect(result.loadedCount).toBe(0); + expect(result.skippedCount).toBe(1); + expect(result.historyImagesByIndex.size).toBe(0); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/pi-embedded-runner/run/images.ts b/src/agents/pi-embedded-runner/run/images.ts index c11f191e4f4a..022950659e1c 100644 --- a/src/agents/pi-embedded-runner/run/images.ts +++ b/src/agents/pi-embedded-runner/run/images.ts @@ -4,6 +4,7 @@ import type { ImageContent } from "@mariozechner/pi-ai"; import { resolveUserPath } from "../../../utils.js"; import { loadWebMedia } from "../../../web/media.js"; import type { ImageSanitizationLimits } from "../../image-sanitization.js"; +import { assertSandboxPath } from "../../sandbox-paths.js"; import type { SandboxFsBridge } from "../../sandbox/fs-bridge.js"; import { sanitizeImageBlocks } from "../../tool-images.js"; import { log } from "../logger.js"; @@ -181,6 +182,7 @@ export async function loadImageFromRef( workspaceDir: string, options?: { maxBytes?: number; + workspaceOnly?: boolean; sandbox?: { root: string; bridge: SandboxFsBridge }; }, ): Promise { @@ -211,6 +213,14 @@ export async function loadImageFromRef( } else if (!path.isAbsolute(targetPath)) { targetPath = path.resolve(workspaceDir, targetPath); } + if (options?.workspaceOnly) { + const root = options?.sandbox?.root ?? workspaceDir; + await assertSandboxPath({ + filePath: targetPath, + cwd: root, + root, + }); + } } // loadWebMedia handles local file paths (including file:// URLs) @@ -361,6 +371,7 @@ export async function detectAndLoadPromptImages(params: { historyMessages?: unknown[]; maxBytes?: number; maxDimensionPx?: number; + workspaceOnly?: boolean; sandbox?: { root: string; bridge: SandboxFsBridge }; }): Promise<{ /** Images for the current prompt (existingImages + detected in current prompt) */ @@ -422,6 +433,7 @@ export async function detectAndLoadPromptImages(params: { for (const ref of allRefs) { const image = await loadImageFromRef(ref, params.workspaceDir, { maxBytes: params.maxBytes, + workspaceOnly: params.workspaceOnly, sandbox: params.sandbox, }); if (image) { From ebb5680893a44b9ce08c0ce1e610069a7c5c8828 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 24 Feb 2026 13:09:20 +0000 Subject: [PATCH 183/408] ui(chat): allowlist image open URLs --- ui/src/ui/chat/grouped-render.ts | 8 +++++- ui/src/ui/chat/image-open.test.ts | 48 +++++++++++++++++++++++++++++++ ui/src/ui/chat/image-open.ts | 39 +++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 ui/src/ui/chat/image-open.test.ts create mode 100644 ui/src/ui/chat/image-open.ts diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 4726596c6e12..e8993f0c29d8 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -5,6 +5,7 @@ import { toSanitizedMarkdownHtml } from "../markdown.ts"; import { detectTextDirection } from "../text-direction.ts"; import type { MessageGroup } from "../types/chat-types.ts"; import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts"; +import { resolveSafeImageOpenUrl } from "./image-open.ts"; import { extractTextCached, extractThinkingCached, @@ -201,7 +202,12 @@ function renderMessageImages(images: ImageBlock[]) { } const openImage = (url: string) => { - const opened = window.open(url, "_blank", "noopener,noreferrer"); + const safeUrl = resolveSafeImageOpenUrl(url, window.location.href); + if (!safeUrl) { + return; + } + + const opened = window.open(safeUrl, "_blank", "noopener,noreferrer"); if (opened) { opened.opener = null; } diff --git a/ui/src/ui/chat/image-open.test.ts b/ui/src/ui/chat/image-open.test.ts new file mode 100644 index 000000000000..180e909bb098 --- /dev/null +++ b/ui/src/ui/chat/image-open.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { resolveSafeImageOpenUrl } from "./image-open.ts"; + +describe("resolveSafeImageOpenUrl", () => { + const baseHref = "https://openclaw.ai/chat"; + + it("allows absolute https URLs", () => { + expect(resolveSafeImageOpenUrl("https://example.com/a.png?x=1#y", baseHref)).toBe( + "https://example.com/a.png?x=1#y", + ); + }); + + it("allows relative URLs resolved against the current origin", () => { + expect(resolveSafeImageOpenUrl("/assets/pic.png", baseHref)).toBe( + "https://openclaw.ai/assets/pic.png", + ); + }); + + it("allows blob URLs", () => { + expect(resolveSafeImageOpenUrl("blob:https://openclaw.ai/abc-123", baseHref)).toBe( + "blob:https://openclaw.ai/abc-123", + ); + }); + + it("allows data image URLs", () => { + expect(resolveSafeImageOpenUrl("data:image/png;base64,iVBORw0KGgo=", baseHref)).toBe( + "data:image/png;base64,iVBORw0KGgo=", + ); + }); + + it("rejects non-image data URLs", () => { + expect( + resolveSafeImageOpenUrl("data:text/html,", baseHref), + ).toBeNull(); + }); + + it("rejects javascript URLs", () => { + expect(resolveSafeImageOpenUrl("javascript:alert(1)", baseHref)).toBeNull(); + }); + + it("rejects file URLs", () => { + expect(resolveSafeImageOpenUrl("file:///tmp/x.png", baseHref)).toBeNull(); + }); + + it("rejects empty values", () => { + expect(resolveSafeImageOpenUrl(" ", baseHref)).toBeNull(); + }); +}); diff --git a/ui/src/ui/chat/image-open.ts b/ui/src/ui/chat/image-open.ts new file mode 100644 index 000000000000..fd096c671846 --- /dev/null +++ b/ui/src/ui/chat/image-open.ts @@ -0,0 +1,39 @@ +const DATA_URL_PREFIX = "data:"; +const ALLOWED_OPEN_PROTOCOLS = new Set(["http:", "https:", "blob:"]); + +function isAllowedDataImageUrl(url: string): boolean { + if (!url.toLowerCase().startsWith(DATA_URL_PREFIX)) { + return false; + } + + const commaIndex = url.indexOf(","); + if (commaIndex < DATA_URL_PREFIX.length) { + return false; + } + + const metadata = url.slice(DATA_URL_PREFIX.length, commaIndex); + const mimeType = metadata.split(";")[0]?.trim().toLowerCase() ?? ""; + return mimeType.startsWith("image/"); +} + +export function resolveSafeImageOpenUrl(rawUrl: string, baseHref: string): string | null { + const candidate = rawUrl.trim(); + if (!candidate) { + return null; + } + + if (isAllowedDataImageUrl(candidate)) { + return candidate; + } + + if (candidate.toLowerCase().startsWith(DATA_URL_PREFIX)) { + return null; + } + + try { + const parsed = new URL(candidate, baseHref); + return ALLOWED_OPEN_PROTOCOLS.has(parsed.protocol.toLowerCase()) ? parsed.toString() : null; + } catch { + return null; + } +} From e5836283abf81b0b40a26ddd698ce9a2d7012976 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 24 Feb 2026 13:27:04 +0000 Subject: [PATCH 184/408] ui: centralize safe external URL opening --- package.json | 3 +- scripts/check-no-raw-window-open.mjs | 87 +++++++++++++++++++ ui/src/ui/chat/grouped-render.ts | 12 +-- ui/src/ui/chat/image-open.test.ts | 48 ---------- ui/src/ui/chat/image-open.ts | 39 --------- ui/src/ui/open-external-url.test.ts | 56 ++++++++++++ ui/src/ui/open-external-url.ts | 68 +++++++++++++++ .../ui/views/chat-image-open.browser.test.ts | 53 +++++++++++ 8 files changed, 268 insertions(+), 98 deletions(-) create mode 100644 scripts/check-no-raw-window-open.mjs delete mode 100644 ui/src/ui/chat/image-open.test.ts delete mode 100644 ui/src/ui/chat/image-open.ts create mode 100644 ui/src/ui/open-external-url.test.ts create mode 100644 ui/src/ui/open-external-url.ts create mode 100644 ui/src/ui/views/chat-image-open.browser.test.ts diff --git a/package.json b/package.json index c1b68821083d..92e04fb7defb 100644 --- a/package.json +++ b/package.json @@ -87,12 +87,13 @@ "ios:gen": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate'", "ios:open": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'", "ios:run": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'", - "lint": "oxlint --type-aware", + "lint": "oxlint --type-aware && pnpm lint:ui:no-raw-window-open", "lint:all": "pnpm lint && pnpm lint:swift", "lint:docs": "pnpm dlx markdownlint-cli2", "lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix", "lint:fix": "oxlint --type-aware --fix && pnpm format", "lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", + "lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs", "mac:open": "open dist/OpenClaw.app", "mac:package": "bash scripts/package-mac-app.sh", "mac:restart": "bash scripts/restart-mac.sh", diff --git a/scripts/check-no-raw-window-open.mjs b/scripts/check-no-raw-window-open.mjs new file mode 100644 index 000000000000..55334549ba19 --- /dev/null +++ b/scripts/check-no-raw-window-open.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const uiSourceDir = path.join(repoRoot, "ui", "src", "ui"); +const allowedCallsites = new Set([path.join(uiSourceDir, "open-external-url.ts")]); + +function isTestFile(filePath) { + return ( + filePath.endsWith(".test.ts") || + filePath.endsWith(".browser.test.ts") || + filePath.endsWith(".node.test.ts") + ); +} + +async function collectTypeScriptFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const out = []; + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...(await collectTypeScriptFiles(entryPath))); + continue; + } + if (!entry.isFile()) { + continue; + } + if (!entryPath.endsWith(".ts")) { + continue; + } + if (isTestFile(entryPath)) { + continue; + } + out.push(entryPath); + } + return out; +} + +function lineNumberAt(content, index) { + let lines = 1; + for (let i = 0; i < index; i++) { + if (content.charCodeAt(i) === 10) { + lines++; + } + } + return lines; +} + +async function main() { + const files = await collectTypeScriptFiles(uiSourceDir); + const violations = []; + const rawWindowOpenRe = /\bwindow\s*\.\s*open\s*\(/g; + + for (const filePath of files) { + if (allowedCallsites.has(filePath)) { + continue; + } + + const content = await fs.readFile(filePath, "utf8"); + let match = rawWindowOpenRe.exec(content); + while (match) { + const line = lineNumberAt(content, match.index); + const relPath = path.relative(repoRoot, filePath); + violations.push(`${relPath}:${line}`); + match = rawWindowOpenRe.exec(content); + } + } + + if (violations.length === 0) { + return; + } + + console.error("Found raw window.open usage outside safe helper:"); + for (const violation of violations) { + console.error(`- ${violation}`); + } + console.error("Use openExternalUrlSafe(...) from ui/src/ui/open-external-url.ts instead."); + process.exit(1); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index e8993f0c29d8..df4689b0fa4a 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -2,10 +2,10 @@ import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import type { AssistantIdentity } from "../assistant-identity.ts"; import { toSanitizedMarkdownHtml } from "../markdown.ts"; +import { openExternalUrlSafe } from "../open-external-url.ts"; import { detectTextDirection } from "../text-direction.ts"; import type { MessageGroup } from "../types/chat-types.ts"; import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts"; -import { resolveSafeImageOpenUrl } from "./image-open.ts"; import { extractTextCached, extractThinkingCached, @@ -202,15 +202,7 @@ function renderMessageImages(images: ImageBlock[]) { } const openImage = (url: string) => { - const safeUrl = resolveSafeImageOpenUrl(url, window.location.href); - if (!safeUrl) { - return; - } - - const opened = window.open(safeUrl, "_blank", "noopener,noreferrer"); - if (opened) { - opened.opener = null; - } + openExternalUrlSafe(url, { allowDataImage: true }); }; return html` diff --git a/ui/src/ui/chat/image-open.test.ts b/ui/src/ui/chat/image-open.test.ts deleted file mode 100644 index 180e909bb098..000000000000 --- a/ui/src/ui/chat/image-open.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveSafeImageOpenUrl } from "./image-open.ts"; - -describe("resolveSafeImageOpenUrl", () => { - const baseHref = "https://openclaw.ai/chat"; - - it("allows absolute https URLs", () => { - expect(resolveSafeImageOpenUrl("https://example.com/a.png?x=1#y", baseHref)).toBe( - "https://example.com/a.png?x=1#y", - ); - }); - - it("allows relative URLs resolved against the current origin", () => { - expect(resolveSafeImageOpenUrl("/assets/pic.png", baseHref)).toBe( - "https://openclaw.ai/assets/pic.png", - ); - }); - - it("allows blob URLs", () => { - expect(resolveSafeImageOpenUrl("blob:https://openclaw.ai/abc-123", baseHref)).toBe( - "blob:https://openclaw.ai/abc-123", - ); - }); - - it("allows data image URLs", () => { - expect(resolveSafeImageOpenUrl("data:image/png;base64,iVBORw0KGgo=", baseHref)).toBe( - "data:image/png;base64,iVBORw0KGgo=", - ); - }); - - it("rejects non-image data URLs", () => { - expect( - resolveSafeImageOpenUrl("data:text/html,", baseHref), - ).toBeNull(); - }); - - it("rejects javascript URLs", () => { - expect(resolveSafeImageOpenUrl("javascript:alert(1)", baseHref)).toBeNull(); - }); - - it("rejects file URLs", () => { - expect(resolveSafeImageOpenUrl("file:///tmp/x.png", baseHref)).toBeNull(); - }); - - it("rejects empty values", () => { - expect(resolveSafeImageOpenUrl(" ", baseHref)).toBeNull(); - }); -}); diff --git a/ui/src/ui/chat/image-open.ts b/ui/src/ui/chat/image-open.ts deleted file mode 100644 index fd096c671846..000000000000 --- a/ui/src/ui/chat/image-open.ts +++ /dev/null @@ -1,39 +0,0 @@ -const DATA_URL_PREFIX = "data:"; -const ALLOWED_OPEN_PROTOCOLS = new Set(["http:", "https:", "blob:"]); - -function isAllowedDataImageUrl(url: string): boolean { - if (!url.toLowerCase().startsWith(DATA_URL_PREFIX)) { - return false; - } - - const commaIndex = url.indexOf(","); - if (commaIndex < DATA_URL_PREFIX.length) { - return false; - } - - const metadata = url.slice(DATA_URL_PREFIX.length, commaIndex); - const mimeType = metadata.split(";")[0]?.trim().toLowerCase() ?? ""; - return mimeType.startsWith("image/"); -} - -export function resolveSafeImageOpenUrl(rawUrl: string, baseHref: string): string | null { - const candidate = rawUrl.trim(); - if (!candidate) { - return null; - } - - if (isAllowedDataImageUrl(candidate)) { - return candidate; - } - - if (candidate.toLowerCase().startsWith(DATA_URL_PREFIX)) { - return null; - } - - try { - const parsed = new URL(candidate, baseHref); - return ALLOWED_OPEN_PROTOCOLS.has(parsed.protocol.toLowerCase()) ? parsed.toString() : null; - } catch { - return null; - } -} diff --git a/ui/src/ui/open-external-url.test.ts b/ui/src/ui/open-external-url.test.ts new file mode 100644 index 000000000000..8972516ed57e --- /dev/null +++ b/ui/src/ui/open-external-url.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { resolveSafeExternalUrl } from "./open-external-url.ts"; + +describe("resolveSafeExternalUrl", () => { + const baseHref = "https://openclaw.ai/chat"; + + it("allows absolute https URLs", () => { + expect(resolveSafeExternalUrl("https://example.com/a.png?x=1#y", baseHref)).toBe( + "https://example.com/a.png?x=1#y", + ); + }); + + it("allows relative URLs resolved against the current origin", () => { + expect(resolveSafeExternalUrl("/assets/pic.png", baseHref)).toBe( + "https://openclaw.ai/assets/pic.png", + ); + }); + + it("allows blob URLs", () => { + expect(resolveSafeExternalUrl("blob:https://openclaw.ai/abc-123", baseHref)).toBe( + "blob:https://openclaw.ai/abc-123", + ); + }); + + it("allows data image URLs when enabled", () => { + expect( + resolveSafeExternalUrl("data:image/png;base64,iVBORw0KGgo=", baseHref, { + allowDataImage: true, + }), + ).toBe("data:image/png;base64,iVBORw0KGgo="); + }); + + it("rejects non-image data URLs", () => { + expect( + resolveSafeExternalUrl("data:text/html,", baseHref, { + allowDataImage: true, + }), + ).toBeNull(); + }); + + it("rejects data image URLs unless explicitly enabled", () => { + expect(resolveSafeExternalUrl("data:image/png;base64,iVBORw0KGgo=", baseHref)).toBeNull(); + }); + + it("rejects javascript URLs", () => { + expect(resolveSafeExternalUrl("javascript:alert(1)", baseHref)).toBeNull(); + }); + + it("rejects file URLs", () => { + expect(resolveSafeExternalUrl("file:///tmp/x.png", baseHref)).toBeNull(); + }); + + it("rejects empty values", () => { + expect(resolveSafeExternalUrl(" ", baseHref)).toBeNull(); + }); +}); diff --git a/ui/src/ui/open-external-url.ts b/ui/src/ui/open-external-url.ts new file mode 100644 index 000000000000..321e69a71fcd --- /dev/null +++ b/ui/src/ui/open-external-url.ts @@ -0,0 +1,68 @@ +const DATA_URL_PREFIX = "data:"; +const ALLOWED_EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "blob:"]); + +function isAllowedDataImageUrl(url: string): boolean { + if (!url.toLowerCase().startsWith(DATA_URL_PREFIX)) { + return false; + } + + const commaIndex = url.indexOf(","); + if (commaIndex < DATA_URL_PREFIX.length) { + return false; + } + + const metadata = url.slice(DATA_URL_PREFIX.length, commaIndex); + const mimeType = metadata.split(";")[0]?.trim().toLowerCase() ?? ""; + return mimeType.startsWith("image/"); +} + +export type ResolveSafeExternalUrlOptions = { + allowDataImage?: boolean; +}; + +export function resolveSafeExternalUrl( + rawUrl: string, + baseHref: string, + opts: ResolveSafeExternalUrlOptions = {}, +): string | null { + const candidate = rawUrl.trim(); + if (!candidate) { + return null; + } + + if (opts.allowDataImage === true && isAllowedDataImageUrl(candidate)) { + return candidate; + } + + if (candidate.toLowerCase().startsWith(DATA_URL_PREFIX)) { + return null; + } + + try { + const parsed = new URL(candidate, baseHref); + return ALLOWED_EXTERNAL_PROTOCOLS.has(parsed.protocol.toLowerCase()) ? parsed.toString() : null; + } catch { + return null; + } +} + +export type OpenExternalUrlSafeOptions = ResolveSafeExternalUrlOptions & { + baseHref?: string; +}; + +export function openExternalUrlSafe( + rawUrl: string, + opts: OpenExternalUrlSafeOptions = {}, +): WindowProxy | null { + const baseHref = opts.baseHref ?? window.location.href; + const safeUrl = resolveSafeExternalUrl(rawUrl, baseHref, opts); + if (!safeUrl) { + return null; + } + + const opened = window.open(safeUrl, "_blank", "noopener,noreferrer"); + if (opened) { + opened.opener = null; + } + return opened; +} diff --git a/ui/src/ui/views/chat-image-open.browser.test.ts b/ui/src/ui/views/chat-image-open.browser.test.ts new file mode 100644 index 000000000000..60e6df26554a --- /dev/null +++ b/ui/src/ui/views/chat-image-open.browser.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mountApp, registerAppMountHooks } from "../test-helpers/app-mount.ts"; + +registerAppMountHooks(); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function renderAssistantImage(url: string) { + return { + role: "assistant", + content: [{ type: "image_url", image_url: { url } }], + timestamp: Date.now(), + }; +} + +describe("chat image open safety", () => { + it("opens safe image URLs in a hardened new tab", async () => { + const app = mountApp("/chat"); + await app.updateComplete; + + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + app.chatMessages = [renderAssistantImage("https://example.com/cat.png")]; + await app.updateComplete; + + const image = app.querySelector(".chat-message-image"); + expect(image).not.toBeNull(); + image?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy).toHaveBeenCalledWith( + "https://example.com/cat.png", + "_blank", + "noopener,noreferrer", + ); + }); + + it("does not open unsafe image URLs", async () => { + const app = mountApp("/chat"); + await app.updateComplete; + + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + app.chatMessages = [renderAssistantImage("javascript:alert(1)")]; + await app.updateComplete; + + const image = app.querySelector(".chat-message-image"); + expect(image).not.toBeNull(); + image?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(openSpy).not.toHaveBeenCalled(); + }); +}); From 94942df8c7ed3b55669d354ceb046dcbea4c4aee Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 24 Feb 2026 13:30:04 +0000 Subject: [PATCH 185/408] build: scope window.open guard to ui checks --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 92e04fb7defb..66a60a5dc00b 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "ios:gen": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate'", "ios:open": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'", "ios:run": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'", - "lint": "oxlint --type-aware && pnpm lint:ui:no-raw-window-open", + "lint": "oxlint --type-aware", "lint:all": "pnpm lint && pnpm lint:swift", "lint:docs": "pnpm dlx markdownlint-cli2", "lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix", @@ -129,7 +129,7 @@ "test:install:smoke": "bash scripts/test-install-sh-docker.sh", "test:live": "OPENCLAW_LIVE_TEST=1 CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts", "test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=serial node scripts/test-parallel.mjs", - "test:ui": "pnpm --dir ui test", + "test:ui": "pnpm lint:ui:no-raw-window-open && pnpm --dir ui test", "test:voicecall:closedloop": "vitest run extensions/voice-call/src/manager.test.ts extensions/voice-call/src/media-stream.test.ts src/plugins/voice-call.plugin.test.ts --maxWorkers=1", "test:watch": "vitest", "tui": "node scripts/run-node.mjs tui", From fb8edebc320ecde685f6750ce052050ba90f1217 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:47:39 +0000 Subject: [PATCH 186/408] fix(ui): stabilize chat-image open browser test and changelog --- CHANGELOG.md | 2 +- ui/src/ui/views/chat-image-open.browser.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b100a2808180..bf6ce0d2135b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ Docs: https://docs.openclaw.ai - Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. - Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. - macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. -- Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase. +- Control UI/Chat images: centralize safe external URL opening for image clicks (allowlist `http/https/blob` + opt-in `data:image/*`) and enforce opener isolation (`noopener,noreferrer` + `window.opener = null`) to prevent tabnabbing/unsafe schemes. (#25444) Thanks @shakkernerd. - CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. - Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey. diff --git a/ui/src/ui/views/chat-image-open.browser.test.ts b/ui/src/ui/views/chat-image-open.browser.test.ts index 60e6df26554a..768c5968100f 100644 --- a/ui/src/ui/views/chat-image-open.browser.test.ts +++ b/ui/src/ui/views/chat-image-open.browser.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import "../app.ts"; import { mountApp, registerAppMountHooks } from "../test-helpers/app-mount.ts"; registerAppMountHooks(); From dd9ba974d0353adb5d35722d936a35724f0bb5a5 Mon Sep 17 00:00:00 2001 From: Glucksberg Date: Mon, 23 Feb 2026 02:21:34 +0000 Subject: [PATCH 187/408] fix: sort IPv4 addresses before IPv6 in SSRF pinned DNS to fix Telegram media fetch on IPv6-broken hosts On hosts where IPv6 is configured but not routed (common on cloud VMs), Telegram media downloads fail because the pinned DNS lookup may return IPv6 addresses first. Even though autoSelectFamily (Happy Eyeballs) is enabled, the round-robin pinned lookup serves individual IPv6 addresses that fail before IPv4 is attempted. Sort resolved addresses so IPv4 comes first, ensuring both Happy Eyeballs and single-address round-robin try the working address family first. Fixes #23975 Co-Authored-By: Claude Opus 4.6 --- src/infra/net/ssrf.pinning.test.ts | 17 +++++++++++++++++ src/infra/net/ssrf.ts | 13 ++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 19d61bdaee84..73f91d9d5368 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -155,6 +155,23 @@ describe("ssrf pinning", () => { expect(lookup).not.toHaveBeenCalled(); }); + it("sorts IPv4 addresses before IPv6 in pinned results", async () => { + const lookup = vi.fn(async () => [ + { address: "2001:db8::1", family: 6 }, + { address: "93.184.216.34", family: 4 }, + { address: "2001:db8::2", family: 6 }, + { address: "93.184.216.35", family: 4 }, + ]) as unknown as LookupFn; + + const pinned = await resolvePinnedHostname("example.com", lookup); + expect(pinned.addresses).toEqual([ + "93.184.216.34", + "93.184.216.35", + "2001:db8::1", + "2001:db8::2", + ]); + }); + it("allows ISATAP embedded private IPv4 when private network is explicitly enabled", async () => { const lookup = vi.fn(async () => [ { address: "2001:db8:1234::5efe:127.0.0.1", family: 6 }, diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 2e4c69210d6c..0d77bfeb35d8 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -290,7 +290,18 @@ export async function resolvePinnedHostnameWithPolicy( assertAllowedResolvedAddressesOrThrow(results, params.policy); } - const addresses = Array.from(new Set(results.map((entry) => entry.address))); + // Sort IPv4 addresses before IPv6 so that Happy Eyeballs (autoSelectFamily) and + // round-robin pinned lookups try IPv4 first. This avoids connection failures on + // hosts where IPv6 is configured but not routed (common on cloud VMs and WSL2). + // See: https://github.com/openclaw/openclaw/issues/23975 + const addresses = Array.from(new Set(results.map((entry) => entry.address))).toSorted((a, b) => { + const aIsV6 = a.includes(":"); + const bIsV6 = b.includes(":"); + if (aIsV6 === bIsV6) { + return 0; + } + return aIsV6 ? 1 : -1; + }); if (addresses.length === 0) { throw new Error(`Unable to resolve hostname: ${hostname}`); } From 3f07d725b177ab0c8a1c72db754e2eba06944998 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:52:16 +0000 Subject: [PATCH 188/408] fix: changelog credit for Telegram IPv4 fallback fix (#24295) (thanks @Glucksberg) Co-Authored-By: Glucksberg <80581902+Glucksberg@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf6ce0d2135b..d50d19e181c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. +- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. - Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. - Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. - Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. From 203de14211a6a40bf32301437e9a9de1d527031c Mon Sep 17 00:00:00 2001 From: zerone0x Date: Tue, 24 Feb 2026 11:09:31 +0100 Subject: [PATCH 189/408] fix(doctor): use plugin manifest id for third-party channel auto-enable When a third-party channel plugin declares a channel ID that differs from its plugin ID (e.g. plugin id="apn-channel", channels=["apn"]), the doctor plugin auto-enable logic was using the channel ID ("apn") as the key for plugins.entries, producing an entry that fails config validation: Error: plugins.entries.apn: plugin not found: apn Root cause: resolveConfiguredPlugins iterated over cfg.channels keys and used each key directly as both the channel ID (for isChannelConfigured) and the plugin ID (for plugins.entries). For built-in channels these are always the same, but for third-party plugins they can differ. Fix: load the installed plugin manifest registry and build a reverse map from channel ID to plugin ID. When a cfg.channels key does not resolve to a built-in channel, look up the declaring plugin's manifest ID and use that as the pluginId in the PluginEnableChange, so registerPluginEntry writes the correct plugins.entries["apn-channel"] key. The applyPluginAutoEnable function now accepts an optional manifestRegistry parameter for testing, avoiding filesystem access in unit tests. Fixes #25261 Co-Authored-By: Claude --- src/config/plugin-auto-enable.test.ts | 79 +++++++++++++++++++++++++++ src/config/plugin-auto-enable.ts | 70 +++++++++++++++++++++--- 2 files changed, 140 insertions(+), 9 deletions(-) diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index f3ef2961f4e5..943dbe346bd5 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -1,7 +1,27 @@ import { describe, expect, it } from "vitest"; +import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { validateConfigObject } from "./config.js"; import { applyPluginAutoEnable } from "./plugin-auto-enable.js"; +/** Helper to build a minimal PluginManifestRegistry for testing. */ +function makeRegistry( + plugins: Array<{ id: string; channels: string[] }>, +): PluginManifestRegistry { + return { + plugins: plugins.map((p) => ({ + id: p.id, + channels: p.channels, + providers: [], + skills: [], + origin: "config" as const, + rootDir: `/fake/${p.id}`, + source: `/fake/${p.id}/index.js`, + manifestPath: `/fake/${p.id}/openclaw.plugin.json`, + })), + diagnostics: [], + }; +} + describe("applyPluginAutoEnable", () => { it("auto-enables built-in channels and appends to existing allowlist", () => { const result = applyPluginAutoEnable({ @@ -136,6 +156,65 @@ describe("applyPluginAutoEnable", () => { expect(result.changes).toEqual([]); }); + describe("third-party channel plugins (pluginId ≠ channelId)", () => { + it("uses the plugin manifest id, not the channel id, for plugins.entries", () => { + // Reproduces: https://github.com/openclaw/openclaw/issues/25261 + // Plugin "apn-channel" declares channels: ["apn"]. Doctor must write + // plugins.entries["apn-channel"], not plugins.entries["apn"]. + const result = applyPluginAutoEnable({ + config: { + channels: { apn: { someKey: "value" } }, + }, + env: {}, + manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]), + }); + + expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true); + expect(result.config.plugins?.entries?.["apn"]).toBeUndefined(); + expect(result.changes.join("\n")).toContain("apn configured, enabled automatically."); + }); + + it("does not double-enable when plugin is already enabled under its plugin id", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { apn: { someKey: "value" } }, + plugins: { entries: { "apn-channel": { enabled: true } } }, + }, + env: {}, + manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]), + }); + + expect(result.changes).toEqual([]); + }); + + it("respects explicit disable of the plugin by its plugin id", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { apn: { someKey: "value" } }, + plugins: { entries: { "apn-channel": { enabled: false } } }, + }, + env: {}, + manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]), + }); + + expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(false); + expect(result.changes).toEqual([]); + }); + + it("falls back to channel key as plugin id when no installed manifest declares the channel", () => { + // Without a matching manifest entry, behavior is unchanged (backward compat). + const result = applyPluginAutoEnable({ + config: { + channels: { "unknown-chan": { someKey: "value" } }, + }, + env: {}, + manifestRegistry: makeRegistry([]), + }); + + expect(result.config.plugins?.entries?.["unknown-chan"]?.enabled).toBe(true); + }); + }); + describe("preferOver channel prioritization", () => { it("prefers bluebubbles: skips imessage auto-configure when both are configured", () => { const result = applyPluginAutoEnable({ diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 63657e3ea214..434650c17776 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -8,6 +8,10 @@ import { listChatChannels, normalizeChatChannelId, } from "../channels/registry.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRegistry, +} from "../plugins/manifest-registry.js"; import { isRecord } from "../utils.js"; import { hasAnyWhatsAppAuth } from "../web/accounts.js"; import type { OpenClawConfig } from "./config.js"; @@ -309,32 +313,74 @@ function isProviderConfigured(cfg: OpenClawConfig, providerId: string): boolean return false; } +function buildChannelToPluginIdMap(registry: PluginManifestRegistry): Map { + const map = new Map(); + for (const record of registry.plugins) { + for (const channelId of record.channels) { + if (channelId && !map.has(channelId)) { + map.set(channelId, record.id); + } + } + } + return map; +} + +type ChannelPluginPair = { + channelId: string; + pluginId: string; +}; + function resolveConfiguredPlugins( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, + registry: PluginManifestRegistry, ): PluginEnableChange[] { const changes: PluginEnableChange[] = []; - const channelIds = new Set(CHANNEL_PLUGIN_IDS); + // Build reverse map: channel ID → plugin ID from installed plugin manifests. + // This is needed when a third-party plugin declares a channel ID that differs + // from the plugin's own ID (e.g. plugin id="apn-channel", channels=["apn"]). + const channelToPluginId = buildChannelToPluginIdMap(registry); + + // For built-in and catalog entries: channelId === pluginId (they are the same). + const pairs: ChannelPluginPair[] = CHANNEL_PLUGIN_IDS.map((id) => ({ + channelId: id, + pluginId: id, + })); + const configuredChannels = cfg.channels as Record | undefined; if (configuredChannels && typeof configuredChannels === "object") { for (const key of Object.keys(configuredChannels)) { if (key === "defaults" || key === "modelByChannel") { continue; } - channelIds.add(normalizeChatChannelId(key) ?? key); + const builtInId = normalizeChatChannelId(key); + if (builtInId) { + // Built-in channel: channelId and pluginId are the same. + pairs.push({ channelId: builtInId, pluginId: builtInId }); + } else { + // Third-party channel plugin: look up the actual plugin ID from the + // manifest registry. If the plugin declares channels=["apn"] but its + // id is "apn-channel", we must use "apn-channel" as the pluginId so + // that plugins.entries is keyed correctly. Fall back to the channel key + // when no installed manifest declares this channel. + const pluginId = channelToPluginId.get(key) ?? key; + pairs.push({ channelId: key, pluginId }); + } } } - for (const channelId of channelIds) { - if (!channelId) { + + // Deduplicate by channelId, preserving first occurrence. + const seenChannelIds = new Set(); + for (const { channelId, pluginId } of pairs) { + if (!channelId || !pluginId || seenChannelIds.has(channelId)) { continue; } + seenChannelIds.add(channelId); if (isChannelConfigured(cfg, channelId, env)) { - changes.push({ - pluginId: channelId, - reason: `${channelId} configured`, - }); + changes.push({ pluginId, reason: `${channelId} configured` }); } } + for (const mapping of PROVIDER_PLUGIN_IDS) { if (isProviderConfigured(cfg, mapping.providerId)) { changes.push({ @@ -450,9 +496,15 @@ function formatAutoEnableChange(entry: PluginEnableChange): string { export function applyPluginAutoEnable(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; + /** Pre-loaded manifest registry. When omitted, the registry is loaded from + * the installed plugins on disk. Pass an explicit registry in tests to + * avoid filesystem access and control what plugins are "installed". */ + manifestRegistry?: PluginManifestRegistry; }): PluginAutoEnableResult { const env = params.env ?? process.env; - const configured = resolveConfiguredPlugins(params.config, env); + const registry = + params.manifestRegistry ?? loadPluginManifestRegistry({ config: params.config }); + const configured = resolveConfiguredPlugins(params.config, env, registry); if (configured.length === 0) { return { config: params.config, changes: [] }; } From 3b4dac764b6a45e7d6fda3226b471ef873261d1c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:35:25 +0000 Subject: [PATCH 190/408] fix: doctor plugin-id mapping for channel auto-enable (#25275) (thanks @zerone0x) --- CHANGELOG.md | 1 + src/config/plugin-auto-enable.test.ts | 4 +--- src/config/plugin-auto-enable.ts | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d50d19e181c1..50e5af12f003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Control UI/Chat images: centralize safe external URL opening for image clicks (allowlist `http/https/blob` + opt-in `data:image/*`) and enforce opener isolation (`noopener,noreferrer` + `window.opener = null`) to prevent tabnabbing/unsafe schemes. (#25444) Thanks @shakkernerd. - CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. - Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey. +- Doctor/Plugins: auto-enable now resolves third-party channel plugins by manifest plugin id (not channel id), preventing invalid `plugins.entries.` writes when ids differ. (#25275) Thanks @zerone0x. ## 2026.2.23 diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index 943dbe346bd5..1c289b17fdea 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -4,9 +4,7 @@ import { validateConfigObject } from "./config.js"; import { applyPluginAutoEnable } from "./plugin-auto-enable.js"; /** Helper to build a minimal PluginManifestRegistry for testing. */ -function makeRegistry( - plugins: Array<{ id: string; channels: string[] }>, -): PluginManifestRegistry { +function makeRegistry(plugins: Array<{ id: string; channels: string[] }>): PluginManifestRegistry { return { plugins: plugins.map((p) => ({ id: p.id, diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 434650c17776..153f0b304d13 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -502,8 +502,7 @@ export function applyPluginAutoEnable(params: { manifestRegistry?: PluginManifestRegistry; }): PluginAutoEnableResult { const env = params.env ?? process.env; - const registry = - params.manifestRegistry ?? loadPluginManifestRegistry({ config: params.config }); + const registry = params.manifestRegistry ?? loadPluginManifestRegistry({ config: params.config }); const configured = resolveConfiguredPlugins(params.config, env, registry); if (configured.length === 0) { return { config: params.config, changes: [] }; From 9ccc15f3a66cf84c60663f7d0882e5d252f0bbf2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:55:16 +0000 Subject: [PATCH 191/408] docs: update changelog note for native image workspace fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50e5af12f003..ce97f60c7f95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes -- Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. +- Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. - Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. - Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. - Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. From 73f526f025af75f90e2f9ef461a0dfe69aa39532 Mon Sep 17 00:00:00 2001 From: Brian Leach Date: Sat, 21 Feb 2026 11:04:27 -0600 Subject: [PATCH 192/408] fix(ios): support Xcode 16+ team detection and fix ntohl build error Xcode 16+/26 no longer writes IDEProvisioningTeams to the preferences plist, breaking ios-team-id.sh for newly signed-in accounts. Add provisioning profile fallback and actionable error when an account exists but no team ID can be resolved. Also replace ntohl() with UInt32(bigEndian:) for Swift 6 compatibility and gitignore Xcode build output directories. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 6 +++++ scripts/ios-team-id.sh | 50 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index fca34f7d4ff1..b5d3257e7e61 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,12 @@ skills-lock.json # Local iOS signing overrides apps/ios/LocalSigning.xcconfig + +# Xcode build directories (xcodebuild output) +apps/ios/build/ +apps/shared/OpenClawKit/build/ +Swabble/build/ + # Generated protocol schema (produced via pnpm protocol:gen) dist/protocol.schema.json .ant-colony/ diff --git a/scripts/ios-team-id.sh b/scripts/ios-team-id.sh index 9ce1a89f2db6..8c27c03d9b41 100755 --- a/scripts/ios-team-id.sh +++ b/scripts/ios-team-id.sh @@ -80,9 +80,45 @@ load_teams_from_legacy_defaults_key() { ) } +load_teams_from_xcode_managed_profiles() { + local profiles_dir="${HOME}/Library/MobileDevice/Provisioning Profiles" + [[ -d "$profiles_dir" ]] || return 0 + + while IFS= read -r team; do + [[ -z "$team" ]] && continue + append_team "$team" "0" "" + done < <( + for p in "${profiles_dir}"/*.mobileprovision; do + [[ -f "$p" ]] || continue + security cms -D -i "$p" 2>/dev/null \ + | /usr/bin/python3 -c ' +import plistlib, sys +try: + d = plistlib.load(sys.stdin.buffer) + for tid in d.get("TeamIdentifier", []): + print(tid) +except Exception: + pass +' 2>/dev/null + done | sort -u + ) +} + +has_xcode_account() { + local plist_path="${HOME}/Library/Preferences/com.apple.dt.Xcode.plist" + [[ -f "$plist_path" ]] || return 1 + local accts + accts="$(defaults read com.apple.dt.Xcode DVTDeveloperAccountManagerAppleIDLists 2>/dev/null || true)" + [[ -n "$accts" ]] && [[ "$accts" != *"does not exist"* ]] && grep -q 'identifier' <<< "$accts" +} + load_teams_from_xcode_preferences load_teams_from_legacy_defaults_key +if [[ ${#team_ids[@]} -eq 0 ]]; then + load_teams_from_xcode_managed_profiles +fi + if [[ ${#team_ids[@]} -eq 0 && "$allow_keychain_fallback" == "1" ]]; then while IFS= read -r team; do [[ -z "$team" ]] && continue @@ -95,7 +131,19 @@ if [[ ${#team_ids[@]} -eq 0 && "$allow_keychain_fallback" == "1" ]]; then fi if [[ ${#team_ids[@]} -eq 0 ]]; then - if [[ "$allow_keychain_fallback" == "1" ]]; then + if has_xcode_account; then + echo "An Apple account is signed in to Xcode, but no Team ID could be resolved." >&2 + echo "" >&2 + echo "On Xcode 16+, team data is not written until you build a project." >&2 + echo "To fix this, do ONE of the following:" >&2 + echo "" >&2 + echo " 1. Open the iOS project in Xcode, select your Team in Signing &" >&2 + echo " Capabilities, and build once. Then re-run this script." >&2 + echo "" >&2 + echo " 2. Set your Team ID directly:" >&2 + echo " export IOS_DEVELOPMENT_TEAM=" >&2 + echo " Find your Team ID at: https://developer.apple.com/account#MembershipDetailsCard" >&2 + elif [[ "$allow_keychain_fallback" == "1" ]]; then echo "No Apple Team ID found. Open Xcode or install signing certificates first." >&2 else echo "No Apple Team ID found in Xcode accounts. Open Xcode → Settings → Accounts and sign in, then retry." >&2 From fd07861bc3d2d55fcfd772a7711dc62386a31a9e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:20:37 +0000 Subject: [PATCH 193/408] fix(ios): harden team-id profile fallback and tests --- CHANGELOG.md | 1 + scripts/ios-team-id.sh | 5 +- test/scripts/ios-team-id.test.ts | 195 +++++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 test/scripts/ios-team-id.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ce97f60c7f95..7319d8f73895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber. - Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. - Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. +- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach. - macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. - Control UI/Chat images: centralize safe external URL opening for image clicks (allowlist `http/https/blob` + opt-in `data:image/*`) and enforce opener isolation (`noopener,noreferrer` + `window.opener = null`) to prevent tabnabbing/unsafe schemes. (#25444) Thanks @shakkernerd. - CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. diff --git a/scripts/ios-team-id.sh b/scripts/ios-team-id.sh index 8c27c03d9b41..4357722190de 100755 --- a/scripts/ios-team-id.sh +++ b/scripts/ios-team-id.sh @@ -94,7 +94,10 @@ load_teams_from_xcode_managed_profiles() { | /usr/bin/python3 -c ' import plistlib, sys try: - d = plistlib.load(sys.stdin.buffer) + raw = sys.stdin.buffer.read() + if not raw: + raise SystemExit(0) + d = plistlib.loads(raw) for tid in d.get("TeamIdentifier", []): print(tid) except Exception: diff --git a/test/scripts/ios-team-id.test.ts b/test/scripts/ios-team-id.test.ts new file mode 100644 index 000000000000..242e897945d3 --- /dev/null +++ b/test/scripts/ios-team-id.test.ts @@ -0,0 +1,195 @@ +import { execFileSync } from "node:child_process"; +import { chmodSync } from "node:fs"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const SCRIPT = path.join(process.cwd(), "scripts", "ios-team-id.sh"); + +async function writeExecutable(filePath: string, body: string): Promise { + await writeFile(filePath, body, "utf8"); + chmodSync(filePath, 0o755); +} + +function runScript( + homeDir: string, + extraEnv: Record = {}, +): { + ok: boolean; + stdout: string; + stderr: string; +} { + const binDir = path.join(homeDir, "bin"); + const env = { + ...process.env, + HOME: homeDir, + PATH: `${binDir}:${process.env.PATH ?? ""}`, + ...extraEnv, + }; + try { + const stdout = execFileSync("bash", [SCRIPT], { + env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { ok: true, stdout: stdout.trim(), stderr: "" }; + } catch (error) { + const e = error as { + stdout?: string | Buffer; + stderr?: string | Buffer; + }; + const stdout = typeof e.stdout === "string" ? e.stdout : (e.stdout?.toString("utf8") ?? ""); + const stderr = typeof e.stderr === "string" ? e.stderr : (e.stderr?.toString("utf8") ?? ""); + return { ok: false, stdout: stdout.trim(), stderr: stderr.trim() }; + } +} + +describe("scripts/ios-team-id.sh", () => { + it("falls back to Xcode-managed provisioning profiles when preference teams are empty", async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-")); + const binDir = path.join(homeDir, "bin"); + await mkdir(binDir, { recursive: true }); + await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); + await mkdir(path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles"), { + recursive: true, + }); + await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), ""); + await writeFile( + path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles", "one.mobileprovision"), + "stub", + ); + + await writeExecutable( + path.join(binDir, "plutil"), + `#!/usr/bin/env bash +echo '{}'`, + ); + await writeExecutable( + path.join(binDir, "defaults"), + `#!/usr/bin/env bash +if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then + echo '(identifier = "dev@example.com";)' + exit 0 +fi +exit 0`, + ); + await writeExecutable( + path.join(binDir, "security"), + `#!/usr/bin/env bash +if [[ "$1" == "cms" && "$2" == "-D" ]]; then + cat <<'PLIST' + + + + + TeamIdentifier + + ABCDE12345 + + + +PLIST + exit 0 +fi +exit 0`, + ); + + const result = runScript(homeDir); + expect(result.ok).toBe(true); + expect(result.stdout).toBe("ABCDE12345"); + }); + + it("prints actionable guidance when Xcode account exists but no Team ID is resolvable", async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-")); + const binDir = path.join(homeDir, "bin"); + await mkdir(binDir, { recursive: true }); + await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); + await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), ""); + + await writeExecutable( + path.join(binDir, "plutil"), + `#!/usr/bin/env bash +echo '{}'`, + ); + await writeExecutable( + path.join(binDir, "defaults"), + `#!/usr/bin/env bash +if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then + echo '(identifier = "dev@example.com";)' + exit 0 +fi +echo "Domain/default pair of (com.apple.dt.Xcode, $3) does not exist" >&2 +exit 1`, + ); + await writeExecutable( + path.join(binDir, "security"), + `#!/usr/bin/env bash +exit 1`, + ); + + const result = runScript(homeDir); + expect(result.ok).toBe(false); + expect(result.stderr).toContain("An Apple account is signed in to Xcode"); + expect(result.stderr).toContain("IOS_DEVELOPMENT_TEAM"); + }); + + it("honors IOS_PREFERRED_TEAM_ID when multiple profile teams are available", async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-")); + const binDir = path.join(homeDir, "bin"); + await mkdir(binDir, { recursive: true }); + await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); + await mkdir(path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles"), { + recursive: true, + }); + await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), ""); + await writeFile( + path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles", "one.mobileprovision"), + "stub1", + ); + await writeFile( + path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles", "two.mobileprovision"), + "stub2", + ); + + await writeExecutable( + path.join(binDir, "plutil"), + `#!/usr/bin/env bash +echo '{}'`, + ); + await writeExecutable( + path.join(binDir, "defaults"), + `#!/usr/bin/env bash +if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then + echo '(identifier = "dev@example.com";)' + exit 0 +fi +exit 0`, + ); + await writeExecutable( + path.join(binDir, "security"), + `#!/usr/bin/env bash +if [[ "$1" == "cms" && "$2" == "-D" ]]; then + if [[ "$4" == *"one.mobileprovision" ]]; then + cat <<'PLIST' + + +TeamIdentifierAAAAA11111 +PLIST + exit 0 + fi + cat <<'PLIST' + + +TeamIdentifierBBBBB22222 +PLIST + exit 0 +fi +exit 0`, + ); + + const result = runScript(homeDir, { IOS_PREFERRED_TEAM_ID: "BBBBB22222" }); + expect(result.ok).toBe(true); + expect(result.stdout).toBe("BBBBB22222"); + }); +}); From 1ae8c0a589c30b42735adee917f34f43832694ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:43:30 +0000 Subject: [PATCH 194/408] fix(ios): make team-id python lookup cross-platform Co-authored-by: Brian Leach --- scripts/ios-team-id.sh | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/scripts/ios-team-id.sh b/scripts/ios-team-id.sh index 4357722190de..d2956f998ff6 100755 --- a/scripts/ios-team-id.sh +++ b/scripts/ios-team-id.sh @@ -14,6 +14,21 @@ prefer_non_free_team="${IOS_PREFER_NON_FREE_TEAM:-1}" declare -a team_ids=() declare -a team_is_free=() declare -a team_names=() +python_cmd="" + +detect_python() { + local candidate + for candidate in "${IOS_PYTHON_BIN:-}" python3 python /usr/bin/python3; do + [[ -n "$candidate" ]] || continue + if command -v "$candidate" >/dev/null 2>&1; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +python_cmd="$(detect_python || true)" append_team() { local candidate_id="$1" @@ -36,13 +51,14 @@ append_team() { load_teams_from_xcode_preferences() { local plist_path="${HOME}/Library/Preferences/com.apple.dt.Xcode.plist" [[ -f "$plist_path" ]] || return 0 + [[ -n "$python_cmd" ]] || return 0 while IFS=$'\t' read -r team_id is_free team_name; do [[ -z "$team_id" ]] && continue append_team "$team_id" "${is_free:-0}" "${team_name:-}" done < <( plutil -extract IDEProvisioningTeams json -o - "$plist_path" 2>/dev/null \ - | /usr/bin/python3 -c ' + | "$python_cmd" -c ' import json import sys @@ -83,6 +99,7 @@ load_teams_from_legacy_defaults_key() { load_teams_from_xcode_managed_profiles() { local profiles_dir="${HOME}/Library/MobileDevice/Provisioning Profiles" [[ -d "$profiles_dir" ]] || return 0 + [[ -n "$python_cmd" ]] || return 0 while IFS= read -r team; do [[ -z "$team" ]] && continue @@ -91,7 +108,7 @@ load_teams_from_xcode_managed_profiles() { for p in "${profiles_dir}"/*.mobileprovision; do [[ -f "$p" ]] || continue security cms -D -i "$p" 2>/dev/null \ - | /usr/bin/python3 -c ' + | "$python_cmd" -c ' import plistlib, sys try: raw = sys.stdin.buffer.read() From 069c56cd759a6fcd9007b4bee3f97f18d1b6c530 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:53:07 +0000 Subject: [PATCH 195/408] fix(ios): normalize team IDs before preferred match Co-authored-by: Brian Leach --- scripts/ios-team-id.sh | 5 +++++ test/scripts/ios-team-id.test.ts | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/scripts/ios-team-id.sh b/scripts/ios-team-id.sh index d2956f998ff6..0963d8d84994 100755 --- a/scripts/ios-team-id.sh +++ b/scripts/ios-team-id.sh @@ -10,6 +10,8 @@ preferred_team="${IOS_PREFERRED_TEAM_ID:-${OPENCLAW_IOS_DEFAULT_TEAM_ID:-Y5PE65H preferred_team_name="${IOS_PREFERRED_TEAM_NAME:-}" allow_keychain_fallback="${IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK:-0}" prefer_non_free_team="${IOS_PREFER_NON_FREE_TEAM:-1}" +preferred_team="${preferred_team//$'\r'/}" +preferred_team_name="${preferred_team_name//$'\r'/}" declare -a team_ids=() declare -a team_is_free=() @@ -34,6 +36,9 @@ append_team() { local candidate_id="$1" local candidate_is_free="$2" local candidate_name="$3" + candidate_id="${candidate_id//$'\r'/}" + candidate_is_free="${candidate_is_free//$'\r'/}" + candidate_name="${candidate_name//$'\r'/}" [[ -z "$candidate_id" ]] && return local i diff --git a/test/scripts/ios-team-id.test.ts b/test/scripts/ios-team-id.test.ts index 242e897945d3..d39d1a7de6f4 100644 --- a/test/scripts/ios-team-id.test.ts +++ b/test/scripts/ios-team-id.test.ts @@ -192,4 +192,40 @@ exit 0`, expect(result.ok).toBe(true); expect(result.stdout).toBe("BBBBB22222"); }); + + it("matches preferred team IDs even when parser output uses CRLF line endings", async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-")); + const binDir = path.join(homeDir, "bin"); + await mkdir(binDir, { recursive: true }); + await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); + await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), ""); + + await writeExecutable( + path.join(binDir, "plutil"), + `#!/usr/bin/env bash +echo '{}'`, + ); + await writeExecutable( + path.join(binDir, "defaults"), + `#!/usr/bin/env bash +if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then + echo '(identifier = "dev@example.com";)' + exit 0 +fi +exit 0`, + ); + await writeExecutable( + path.join(binDir, "fake-python"), + `#!/usr/bin/env bash +printf 'AAAAA11111\\t0\\tAlpha Team\\r\\n' +printf 'BBBBB22222\\t0\\tBeta Team\\r\\n'`, + ); + + const result = runScript(homeDir, { + IOS_PYTHON_BIN: path.join(binDir, "fake-python"), + IOS_PREFERRED_TEAM_ID: "BBBBB22222", + }); + expect(result.ok).toBe(true); + expect(result.stdout).toBe("BBBBB22222"); + }); }); From d1f28c954e69222227ea82a88ca717bb2d50bd40 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Fri, 20 Feb 2026 15:07:09 +0200 Subject: [PATCH 196/408] feat(gateway): surface talk elevenlabs config metadata --- src/config/schema.help.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 79a256533803..8beedf5c78fb 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -133,6 +133,16 @@ export const FIELD_HELP: Record = { "gateway.remote.sshTarget": "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", "gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).", + "talk.voiceId": + "Default ElevenLabs voice ID for Talk mode (iOS/macOS/Android). Falls back to ELEVENLABS_VOICE_ID or SAG_VOICE_ID when unset.", + "talk.voiceAliases": + 'Optional map of friendly names to ElevenLabs voice IDs for Talk directives (for example {"Clawd":"EXAVITQu4vr4xnSDxMaL"}).', + "talk.modelId": "Default ElevenLabs model ID for Talk mode (default: eleven_v3).", + "talk.outputFormat": + "Default ElevenLabs output format for Talk mode (for example pcm_44100 or mp3_44100_128).", + "talk.apiKey": "ElevenLabs API key for Talk mode. Falls back to ELEVENLABS_API_KEY when unset.", + "talk.interruptOnSpeech": + "If true (default), stop assistant speech when the user starts speaking in Talk mode.", "agents.list.*.skills": "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", "agents.list[].skills": From d58f71571ae3d45927352dcda20a432cf3e57299 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sat, 21 Feb 2026 21:47:39 +0200 Subject: [PATCH 197/408] feat(talk): add provider-agnostic config with legacy compatibility --- .../openclaw/android/voice/TalkModeManager.kt | 74 ++++- .../voice/TalkModeConfigParsingTest.kt | 59 ++++ .../Gateway/GatewaySettingsStore.swift | 55 +++- apps/ios/Sources/Voice/TalkModeManager.swift | 66 ++++- .../ios/Tests/GatewaySettingsStoreTests.swift | 36 +++ .../Tests/TalkModeConfigParsingTests.swift | 34 +++ .../Sources/OpenClaw/TalkModeRuntime.swift | 72 ++++- .../TalkModeConfigParsingTests.swift | 36 +++ src/config/defaults.ts | 50 +++- src/config/io.ts | 31 ++- src/config/schema.help.ts | 20 +- src/config/schema.labels.ts | 7 + src/config/talk.normalize.test.ts | 150 ++++++++++ src/config/talk.ts | 262 ++++++++++++++++++ src/config/types.gateway.ts | 31 ++- src/config/zod-schema.ts | 15 + src/gateway/protocol/schema/channels.ts | 13 + src/gateway/server-methods/talk.ts | 43 +-- src/gateway/server.talk-config.test.ts | 56 +++- 19 files changed, 1002 insertions(+), 108 deletions(-) create mode 100644 apps/android/app/src/test/java/ai/openclaw/android/voice/TalkModeConfigParsingTest.kt create mode 100644 apps/ios/Tests/TalkModeConfigParsingTests.swift create mode 100644 apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift create mode 100644 src/config/talk.normalize.test.ts diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt index 04d18b622602..54bea53bd677 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt @@ -54,6 +54,47 @@ class TalkModeManager( private const val tag = "TalkMode" private const val defaultModelIdFallback = "eleven_v3" private const val defaultOutputFormatFallback = "pcm_24000" + private const val defaultTalkProvider = "elevenlabs" + + internal data class TalkProviderConfigSelection( + val provider: String, + val config: JsonObject, + val normalizedPayload: Boolean, + ) + + private fun normalizeTalkProviderId(raw: String?): String? { + val trimmed = raw?.trim()?.lowercase().orEmpty() + return trimmed.takeIf { it.isNotEmpty() } + } + + internal fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? { + if (talk == null) return null + val rawProvider = talk["provider"].asStringOrNull() + val rawProviders = talk["providers"].asObjectOrNull() + val hasNormalizedPayload = rawProvider != null || rawProviders != null + if (hasNormalizedPayload) { + val providers = + rawProviders?.entries?.mapNotNull { (key, value) -> + val providerId = normalizeTalkProviderId(key) ?: return@mapNotNull null + val providerConfig = value.asObjectOrNull() ?: return@mapNotNull null + providerId to providerConfig + }?.toMap().orEmpty() + val providerId = + normalizeTalkProviderId(rawProvider) + ?: providers.keys.sorted().firstOrNull() + ?: defaultTalkProvider + return TalkProviderConfigSelection( + provider = providerId, + config = providers[providerId] ?: buildJsonObject {}, + normalizedPayload = true, + ) + } + return TalkProviderConfigSelection( + provider = defaultTalkProvider, + config = talk, + normalizedPayload = false, + ) + } } private val mainHandler = Handler(Looper.getMainLooper()) @@ -818,30 +859,49 @@ class TalkModeManager( val root = json.parseToJsonElement(res).asObjectOrNull() val config = root?.get("config").asObjectOrNull() val talk = config?.get("talk").asObjectOrNull() + val selection = selectTalkProviderConfig(talk) + val activeProvider = selection?.provider ?: defaultTalkProvider + val activeConfig = selection?.config val sessionCfg = config?.get("session").asObjectOrNull() val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) - val voice = talk?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val aliases = - talk?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) -> + activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) -> val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null normalizeAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id } }?.toMap().orEmpty() - val model = talk?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } - val outputFormat = talk?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } - val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val outputFormat = + activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull() if (!isCanonicalMainSessionKey(mainSessionKey)) { mainSessionKey = mainKey } - defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } + defaultVoiceId = + if (activeProvider == defaultTalkProvider) { + voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } + } else { + voice + } voiceAliases = aliases if (!voiceOverrideActive) currentVoiceId = defaultVoiceId defaultModelId = model ?: defaultModelIdFallback if (!modelOverrideActive) currentModelId = defaultModelId defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback - apiKey = key ?: envKey?.takeIf { it.isNotEmpty() } + apiKey = + if (activeProvider == defaultTalkProvider) { + key ?: envKey?.takeIf { it.isNotEmpty() } + } else { + null + } if (interrupt != null) interruptOnSpeech = interrupt + if (activeProvider != defaultTalkProvider) { + Log.w(tag, "talk provider $activeProvider unsupported; using system voice fallback") + } else if (selection?.normalizedPayload == true) { + Log.d(tag, "talk config provider=elevenlabs") + } } catch (_: Throwable) { defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } defaultModelId = defaultModelIdFallback diff --git a/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkModeConfigParsingTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkModeConfigParsingTest.kt new file mode 100644 index 000000000000..5daa62080d70 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkModeConfigParsingTest.kt @@ -0,0 +1,59 @@ +package ai.openclaw.android.voice + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TalkModeConfigParsingTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun prefersNormalizedTalkProviderPayload() { + val talk = + json.parseToJsonElement( + """ + { + "provider": "elevenlabs", + "providers": { + "elevenlabs": { + "voiceId": "voice-normalized" + } + }, + "voiceId": "voice-legacy" + } + """.trimIndent(), + ) + .jsonObject + + val selection = TalkModeManager.selectTalkProviderConfig(talk) + assertNotNull(selection) + assertEquals("elevenlabs", selection?.provider) + assertTrue(selection?.normalizedPayload == true) + assertEquals("voice-normalized", selection?.config?.get("voiceId")?.jsonPrimitive?.content) + } + + @Test + fun fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() { + val talk = + json.parseToJsonElement( + """ + { + "voiceId": "voice-legacy", + "apiKey": "legacy-key" + } + """.trimIndent(), + ) + .jsonObject + + val selection = TalkModeManager.selectTalkProviderConfig(talk) + assertNotNull(selection) + assertEquals("elevenlabs", selection?.provider) + assertTrue(selection?.normalizedPayload == false) + assertEquals("voice-legacy", selection?.config?.get("voiceId")?.jsonPrimitive?.content) + assertEquals("legacy-key", selection?.config?.get("apiKey")?.jsonPrimitive?.content) + } +} diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index 3ff57ad2e674..264aa8aa50d9 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -25,7 +25,8 @@ enum GatewaySettingsStore { private static let instanceIdAccount = "instanceId" private static let preferredGatewayStableIDAccount = "preferredStableID" private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID" - private static let talkElevenLabsApiKeyAccount = "elevenlabs.apiKey" + private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." + private static let talkElevenLabsApiKeyLegacyAccount = "elevenlabs.apiKey" static func bootstrapPersistence() { self.ensureStableInstanceID() @@ -145,25 +146,52 @@ enum GatewaySettingsStore { case discovered } - static func loadTalkElevenLabsApiKey() -> String? { + static func loadTalkProviderApiKey(provider: String) -> String? { + guard let providerId = self.normalizedTalkProviderID(provider) else { return nil } + let account = self.talkProviderApiKeyAccount(providerId: providerId) let value = KeychainStore.loadString( service: self.talkService, - account: self.talkElevenLabsApiKeyAccount)? + account: account)? .trimmingCharacters(in: .whitespacesAndNewlines) if value?.isEmpty == false { return value } + + if providerId == "elevenlabs" { + let legacyValue = KeychainStore.loadString( + service: self.talkService, + account: self.talkElevenLabsApiKeyLegacyAccount)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if legacyValue?.isEmpty == false { + _ = KeychainStore.saveString(legacyValue!, service: self.talkService, account: account) + return legacyValue + } + } + return nil } - static func saveTalkElevenLabsApiKey(_ apiKey: String?) { + static func saveTalkProviderApiKey(_ apiKey: String?, provider: String) { + guard let providerId = self.normalizedTalkProviderID(provider) else { return } + let account = self.talkProviderApiKeyAccount(providerId: providerId) let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmed.isEmpty { - _ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyAccount) + _ = KeychainStore.delete(service: self.talkService, account: account) + if providerId == "elevenlabs" { + _ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyLegacyAccount) + } return } - _ = KeychainStore.saveString( - trimmed, - service: self.talkService, - account: self.talkElevenLabsApiKeyAccount) + _ = KeychainStore.saveString(trimmed, service: self.talkService, account: account) + if providerId == "elevenlabs" { + _ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyLegacyAccount) + } + } + + static func loadTalkElevenLabsApiKey() -> String? { + self.loadTalkProviderApiKey(provider: "elevenlabs") + } + + static func saveTalkElevenLabsApiKey(_ apiKey: String?) { + self.saveTalkProviderApiKey(apiKey, provider: "elevenlabs") } static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) { @@ -278,6 +306,15 @@ enum GatewaySettingsStore { "gateway-password.\(instanceId)" } + private static func talkProviderApiKeyAccount(providerId: String) -> String { + self.talkProviderApiKeyAccountPrefix + providerId + } + + private static func normalizedTalkProviderID(_ provider: String) -> String? { + let trimmed = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed.isEmpty ? nil : trimmed + } + private static func ensureStableInstanceID() { let defaults = UserDefaults.standard diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 8f208c66d505..4e1a67945f1b 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -16,6 +16,7 @@ import Speech final class TalkModeManager: NSObject { private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest private static let defaultModelIdFallback = "eleven_v3" + private static let defaultTalkProvider = "elevenlabs" private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__" var isEnabled: Bool = false var isListening: Bool = false @@ -1885,6 +1886,46 @@ extension TalkModeManager { return trimmed } + struct TalkProviderConfigSelection { + let provider: String + let config: [String: Any] + let normalizedPayload: Bool + } + + private static func normalizedTalkProviderID(_ raw: String?) -> String? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed.isEmpty ? nil : trimmed + } + + static func selectTalkProviderConfig(_ talk: [String: Any]?) -> TalkProviderConfigSelection? { + guard let talk else { return nil } + let rawProvider = talk["provider"] as? String + let rawProviders = talk["providers"] as? [String: Any] + let hasNormalized = rawProvider != nil || rawProviders != nil + if hasNormalized { + let providers = rawProviders ?? [:] + let normalizedProviders = providers.reduce(into: [String: [String: Any]]()) { acc, entry in + guard + let providerID = Self.normalizedTalkProviderID(entry.key), + let config = entry.value as? [String: Any] + else { return } + acc[providerID] = config + } + let providerID = + Self.normalizedTalkProviderID(rawProvider) ?? + normalizedProviders.keys.sorted().first ?? + Self.defaultTalkProvider + return TalkProviderConfigSelection( + provider: providerID, + config: normalizedProviders[providerID] ?? [:], + normalizedPayload: true) + } + return TalkProviderConfigSelection( + provider: Self.defaultTalkProvider, + config: talk, + normalizedPayload: false) + } + func reloadConfig() async { guard let gateway else { return } do { @@ -1892,8 +1933,12 @@ extension TalkModeManager { guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return } guard let config = json["config"] as? [String: Any] else { return } let talk = config["talk"] as? [String: Any] - self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - if let aliases = talk?["voiceAliases"] as? [String: Any] { + let selection = Self.selectTalkProviderConfig(talk) + let activeProvider = selection?.provider ?? Self.defaultTalkProvider + let activeConfig = selection?.config + self.defaultVoiceId = (activeConfig?["voiceId"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let aliases = activeConfig?["voiceAliases"] as? [String: Any] { var resolved: [String: String] = [:] for (key, value) in aliases { guard let id = value as? String else { continue } @@ -1909,22 +1954,28 @@ extension TalkModeManager { if !self.voiceOverrideActive { self.currentVoiceId = self.defaultVoiceId } - let model = (talk?["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let model = (activeConfig?["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) self.defaultModelId = (model?.isEmpty == false) ? model : Self.defaultModelIdFallback if !self.modelOverrideActive { self.currentModelId = self.defaultModelId } - self.defaultOutputFormat = (talk?["outputFormat"] as? String)? + self.defaultOutputFormat = (activeConfig?["outputFormat"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) - let rawConfigApiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let rawConfigApiKey = (activeConfig?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey) - let localApiKey = Self.normalizedTalkApiKey(GatewaySettingsStore.loadTalkElevenLabsApiKey()) + let localApiKey = Self.normalizedTalkApiKey( + GatewaySettingsStore.loadTalkProviderApiKey(provider: activeProvider)) if rawConfigApiKey == Self.redactedConfigSentinel { self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : nil GatewayDiagnostics.log("talk config apiKey redacted; using local override if present") } else { self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : configApiKey } + if activeProvider != Self.defaultTalkProvider { + self.apiKey = nil + GatewayDiagnostics.log( + "talk provider '\(activeProvider)' not yet supported on iOS; using system voice fallback") + } self.gatewayTalkDefaultVoiceId = self.defaultVoiceId self.gatewayTalkDefaultModelId = self.defaultModelId self.gatewayTalkApiKeyConfigured = (self.apiKey?.isEmpty == false) @@ -1932,6 +1983,9 @@ extension TalkModeManager { if let interrupt = talk?["interruptOnSpeech"] as? Bool { self.interruptOnSpeech = interrupt } + if selection?.normalizedPayload == true { + GatewayDiagnostics.log("talk config provider=\(activeProvider)") + } } catch { self.defaultModelId = Self.defaultModelIdFallback if !self.modelOverrideActive { diff --git a/apps/ios/Tests/GatewaySettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift index 7e67ab84a972..ec879b3a0f30 100644 --- a/apps/ios/Tests/GatewaySettingsStoreTests.swift +++ b/apps/ios/Tests/GatewaySettingsStoreTests.swift @@ -9,9 +9,15 @@ private struct KeychainEntry: Hashable { private let gatewayService = "ai.openclaw.gateway" private let nodeService = "ai.openclaw.node" +private let talkService = "ai.openclaw.talk" private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId") private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID") private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID") +private let talkElevenLabsLegacyEntry = KeychainEntry(service: talkService, account: "elevenlabs.apiKey") +private let talkElevenLabsProviderEntry = KeychainEntry( + service: talkService, + account: "provider.apiKey.elevenlabs") +private let talkAcmeProviderEntry = KeychainEntry(service: talkService, account: "provider.apiKey.acme") private func snapshotDefaults(_ keys: [String]) -> [String: Any?] { let defaults = UserDefaults.standard @@ -196,4 +202,34 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) { let loaded = GatewaySettingsStore.loadLastGatewayConnection() #expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789")) } + + @Test func talkProviderApiKey_genericRoundTrip() { + let keychainSnapshot = snapshotKeychain([talkAcmeProviderEntry]) + defer { restoreKeychain(keychainSnapshot) } + + _ = KeychainStore.delete(service: talkService, account: talkAcmeProviderEntry.account) + + GatewaySettingsStore.saveTalkProviderApiKey("acme-key", provider: "acme") + #expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == "acme-key") + + GatewaySettingsStore.saveTalkProviderApiKey(nil, provider: "acme") + #expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == nil) + } + + @Test func talkProviderApiKey_elevenlabsLegacyFallbackMigratesToProviderKey() { + let keychainSnapshot = snapshotKeychain([talkElevenLabsLegacyEntry, talkElevenLabsProviderEntry]) + defer { restoreKeychain(keychainSnapshot) } + + _ = KeychainStore.delete(service: talkService, account: talkElevenLabsProviderEntry.account) + _ = KeychainStore.saveString( + "legacy-eleven-key", + service: talkService, + account: talkElevenLabsLegacyEntry.account) + + let loaded = GatewaySettingsStore.loadTalkProviderApiKey(provider: "elevenlabs") + #expect(loaded == "legacy-eleven-key") + #expect( + KeychainStore.loadString(service: talkService, account: talkElevenLabsProviderEntry.account) + == "legacy-eleven-key") + } } diff --git a/apps/ios/Tests/TalkModeConfigParsingTests.swift b/apps/ios/Tests/TalkModeConfigParsingTests.swift new file mode 100644 index 000000000000..fd5c3d0f3923 --- /dev/null +++ b/apps/ios/Tests/TalkModeConfigParsingTests.swift @@ -0,0 +1,34 @@ +import Testing +@testable import OpenClaw + +@Suite struct TalkModeConfigParsingTests { + @Test func prefersNormalizedTalkProviderPayload() async { + let talk: [String: Any] = [ + "provider": "elevenlabs", + "providers": [ + "elevenlabs": [ + "voiceId": "voice-normalized", + ], + ], + "voiceId": "voice-legacy", + ] + + let selection = await MainActor.run { TalkModeManager.selectTalkProviderConfig(talk) } + #expect(selection?.provider == "elevenlabs") + #expect(selection?.normalizedPayload == true) + #expect(selection?.config["voiceId"] as? String == "voice-normalized") + } + + @Test func fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() async { + let talk: [String: Any] = [ + "voiceId": "voice-legacy", + "apiKey": "legacy-key", + ] + + let selection = await MainActor.run { TalkModeManager.selectTalkProviderConfig(talk) } + #expect(selection?.provider == "elevenlabs") + #expect(selection?.normalizedPayload == false) + #expect(selection?.config["voiceId"] as? String == "voice-legacy") + #expect(selection?.config["apiKey"] as? String == "legacy-key") + } +} diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index 47b041a5873e..443bc192295a 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -11,6 +11,7 @@ actor TalkModeRuntime { private let logger = Logger(subsystem: "ai.openclaw", category: "talk.runtime") private let ttsLogger = Logger(subsystem: "ai.openclaw", category: "talk.tts") private static let defaultModelIdFallback = "eleven_v3" + private static let defaultTalkProvider = "elevenlabs" private final class RMSMeter: @unchecked Sendable { private let lock = NSLock() @@ -792,6 +793,48 @@ extension TalkModeRuntime { let apiKey: String? } + struct TalkProviderConfigSelection { + let provider: String + let config: [String: AnyCodable] + let normalizedPayload: Bool + } + + private static func normalizedTalkProviderID(_ raw: String?) -> String? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + static func selectTalkProviderConfig( + _ talk: [String: AnyCodable]?) -> TalkProviderConfigSelection? + { + guard let talk else { return nil } + let rawProvider = talk["provider"]?.stringValue + let rawProviders = talk["providers"]?.dictionaryValue + let hasNormalizedPayload = rawProvider != nil || rawProviders != nil + if hasNormalizedPayload { + let normalizedProviders = + rawProviders?.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in + guard + let providerID = Self.normalizedTalkProviderID(entry.key), + let providerConfig = entry.value.dictionaryValue + else { return } + acc[providerID] = providerConfig + } ?? [:] + let providerID = + Self.normalizedTalkProviderID(rawProvider) ?? + normalizedProviders.keys.sorted().first ?? + Self.defaultTalkProvider + return TalkProviderConfigSelection( + provider: providerID, + config: normalizedProviders[providerID] ?? [:], + normalizedPayload: true) + } + return TalkProviderConfigSelection( + provider: Self.defaultTalkProvider, + config: talk, + normalizedPayload: false) + } + private func fetchTalkConfig() async -> TalkRuntimeConfig { let env = ProcessInfo.processInfo.environment let envVoice = env["ELEVENLABS_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -804,13 +847,16 @@ extension TalkModeRuntime { params: ["includeSecrets": AnyCodable(true)], timeoutMs: 8000) let talk = snap.config?["talk"]?.dictionaryValue + let selection = Self.selectTalkProviderConfig(talk) + let activeProvider = selection?.provider ?? Self.defaultTalkProvider + let activeConfig = selection?.config let ui = snap.config?["ui"]?.dictionaryValue let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" await MainActor.run { AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam } - let voice = talk?["voiceId"]?.stringValue - let rawAliases = talk?["voiceAliases"]?.dictionaryValue + let voice = activeConfig?["voiceId"]?.stringValue + let rawAliases = activeConfig?["voiceAliases"]?.dictionaryValue let resolvedAliases: [String: String] = rawAliases?.reduce(into: [:]) { acc, entry in let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -818,18 +864,30 @@ extension TalkModeRuntime { guard !key.isEmpty, !value.isEmpty else { return } acc[key] = value } ?? [:] - let model = talk?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) let resolvedModel = (model?.isEmpty == false) ? model! : Self.defaultModelIdFallback - let outputFormat = talk?["outputFormat"]?.stringValue + let outputFormat = activeConfig?["outputFormat"]?.stringValue let interrupt = talk?["interruptOnSpeech"]?.boolValue - let apiKey = talk?["apiKey"]?.stringValue - let resolvedVoice = + let apiKey = activeConfig?["apiKey"]?.stringValue + let resolvedVoice: String? = if activeProvider == Self.defaultTalkProvider { (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ?? (envVoice?.isEmpty == false ? envVoice : nil) ?? (sagVoice?.isEmpty == false ? sagVoice : nil) - let resolvedApiKey = + } else { + (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) + } + let resolvedApiKey: String? = if activeProvider == Self.defaultTalkProvider { (envApiKey?.isEmpty == false ? envApiKey : nil) ?? (apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil) + } else { + nil + } + if activeProvider != Self.defaultTalkProvider { + self.ttsLogger + .info("talk provider \(activeProvider, privacy: .public) unsupported; using system voice") + } else if selection?.normalizedPayload == true { + self.ttsLogger.info("talk config provider elevenlabs") + } return TalkRuntimeConfig( voiceId: resolvedVoice, voiceAliases: resolvedAliases, diff --git a/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift b/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift new file mode 100644 index 000000000000..5ee30af273d4 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift @@ -0,0 +1,36 @@ +import OpenClawProtocol +import Testing + +@testable import OpenClaw + +@Suite struct TalkModeConfigParsingTests { + @Test func prefersNormalizedTalkProviderPayload() { + let talk: [String: AnyCodable] = [ + "provider": AnyCodable("elevenlabs"), + "providers": AnyCodable([ + "elevenlabs": [ + "voiceId": "voice-normalized", + ], + ]), + "voiceId": AnyCodable("voice-legacy"), + ] + + let selection = TalkModeRuntime.selectTalkProviderConfig(talk) + #expect(selection?.provider == "elevenlabs") + #expect(selection?.normalizedPayload == true) + #expect(selection?.config["voiceId"]?.stringValue == "voice-normalized") + } + + @Test func fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() { + let talk: [String: AnyCodable] = [ + "voiceId": AnyCodable("voice-legacy"), + "apiKey": AnyCodable("legacy-key"), + ] + + let selection = TalkModeRuntime.selectTalkProviderConfig(talk) + #expect(selection?.provider == "elevenlabs") + #expect(selection?.normalizedPayload == false) + #expect(selection?.config["voiceId"]?.stringValue == "voice-legacy") + #expect(selection?.config["apiKey"]?.stringValue == "legacy-key") + } +} diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 0d281c365667..7c652e6c3196 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -2,7 +2,12 @@ import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js"; import { normalizeProviderId, parseModelRef } from "../agents/model-selection.js"; import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js"; import { resolveAgentModelPrimaryValue } from "./model-input.js"; -import { resolveTalkApiKey } from "./talk.js"; +import { + DEFAULT_TALK_PROVIDER, + normalizeTalkConfig, + resolveActiveTalkProviderConfig, + resolveTalkApiKey, +} from "./talk.js"; import type { OpenClawConfig } from "./types.js"; import type { ModelDefinitionConfig } from "./types.models.js"; @@ -163,23 +168,48 @@ export function applySessionDefaults( } export function applyTalkApiKey(config: OpenClawConfig): OpenClawConfig { + const normalized = normalizeTalkConfig(config); const resolved = resolveTalkApiKey(); if (!resolved) { - return config; + return normalized; } - const existing = config.talk?.apiKey?.trim(); - if (existing) { - return config; + + const talk = normalized.talk; + const active = resolveActiveTalkProviderConfig(talk); + if (active.provider && active.provider !== DEFAULT_TALK_PROVIDER) { + return normalized; + } + + const existingProviderApiKey = + typeof active.config?.apiKey === "string" ? active.config.apiKey.trim() : ""; + const existingLegacyApiKey = typeof talk?.apiKey === "string" ? talk.apiKey.trim() : ""; + if (existingProviderApiKey || existingLegacyApiKey) { + return normalized; } + + const providerId = active.provider ?? DEFAULT_TALK_PROVIDER; + const providers = { ...talk?.providers }; + const providerConfig = { ...providers[providerId], apiKey: resolved }; + providers[providerId] = providerConfig; + + const nextTalk = { + ...talk, + provider: talk?.provider ?? providerId, + providers, + // Keep legacy shape populated during compatibility rollout. + apiKey: resolved, + }; + return { - ...config, - talk: { - ...config.talk, - apiKey: resolved, - }, + ...normalized, + talk: nextTalk, }; } +export function applyTalkConfigNormalization(config: OpenClawConfig): OpenClawConfig { + return normalizeTalkConfig(config); +} + export function applyModelDefaults(cfg: OpenClawConfig): OpenClawConfig { let mutated = false; let nextCfg = cfg; diff --git a/src/config/io.ts b/src/config/io.ts index 01e691f1e600..c74992c49382 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -24,6 +24,7 @@ import { applyMessageDefaults, applyModelDefaults, applySessionDefaults, + applyTalkConfigNormalization, applyTalkApiKey, } from "./defaults.js"; import { restoreEnvVarRefs } from "./env-preserve.js"; @@ -720,11 +721,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { deps.logger.warn(`Config warnings:\\n${details}`); } warnIfConfigFromFuture(validated.config, deps.logger); - const cfg = applyModelDefaults( - applyCompactionDefaults( - applyContextPruningDefaults( - applyAgentDefaults( - applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), + const cfg = applyTalkConfigNormalization( + applyModelDefaults( + applyCompactionDefaults( + applyContextPruningDefaults( + applyAgentDefaults( + applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), + ), ), ), ), @@ -809,10 +812,12 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { if (!exists) { const hash = hashConfigRaw(null); const config = applyTalkApiKey( - applyModelDefaults( - applyCompactionDefaults( - applyContextPruningDefaults( - applyAgentDefaults(applySessionDefaults(applyMessageDefaults({}))), + applyTalkConfigNormalization( + applyModelDefaults( + applyCompactionDefaults( + applyContextPruningDefaults( + applyAgentDefaults(applySessionDefaults(applyMessageDefaults({}))), + ), ), ), ), @@ -933,9 +938,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { warnIfConfigFromFuture(validated.config, deps.logger); const snapshotConfig = normalizeConfigPaths( applyTalkApiKey( - applyModelDefaults( - applyAgentDefaults( - applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), + applyTalkConfigNormalization( + applyModelDefaults( + applyAgentDefaults( + applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), + ), ), ), ), diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 8beedf5c78fb..8bc07121e3d1 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -133,14 +133,24 @@ export const FIELD_HELP: Record = { "gateway.remote.sshTarget": "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", "gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).", + "talk.provider": 'Active Talk provider id (for example "elevenlabs").', + "talk.providers": + "Provider-specific Talk settings keyed by provider id. During migration, prefer this over legacy talk.* keys.", + "talk.providers.*.voiceId": "Provider default voice ID for Talk mode.", + "talk.providers.*.voiceAliases": "Optional provider voice alias map for Talk directives.", + "talk.providers.*.modelId": "Provider default model ID for Talk mode.", + "talk.providers.*.outputFormat": "Provider default output format for Talk mode.", + "talk.providers.*.apiKey": "Provider API key for Talk mode.", "talk.voiceId": - "Default ElevenLabs voice ID for Talk mode (iOS/macOS/Android). Falls back to ELEVENLABS_VOICE_ID or SAG_VOICE_ID when unset.", + "Legacy ElevenLabs default voice ID for Talk mode. Prefer talk.providers.elevenlabs.voiceId.", "talk.voiceAliases": - 'Optional map of friendly names to ElevenLabs voice IDs for Talk directives (for example {"Clawd":"EXAVITQu4vr4xnSDxMaL"}).', - "talk.modelId": "Default ElevenLabs model ID for Talk mode (default: eleven_v3).", + 'Legacy ElevenLabs voice alias map (for example {"Clawd":"EXAVITQu4vr4xnSDxMaL"}). Prefer talk.providers.elevenlabs.voiceAliases.', + "talk.modelId": + "Legacy ElevenLabs model ID for Talk mode (default: eleven_v3). Prefer talk.providers.elevenlabs.modelId.", "talk.outputFormat": - "Default ElevenLabs output format for Talk mode (for example pcm_44100 or mp3_44100_128).", - "talk.apiKey": "ElevenLabs API key for Talk mode. Falls back to ELEVENLABS_API_KEY when unset.", + "Legacy ElevenLabs output format for Talk mode (for example pcm_44100 or mp3_44100_128). Prefer talk.providers.elevenlabs.outputFormat.", + "talk.apiKey": + "Legacy ElevenLabs API key for Talk mode. Prefer talk.providers.elevenlabs.apiKey (fallback: ELEVENLABS_API_KEY).", "talk.interruptOnSpeech": "If true (default), stop assistant speech when the user starts speaking in Talk mode.", "agents.list.*.skills": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 986f3c4b3aa8..397376f6e111 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -600,6 +600,13 @@ export const FIELD_LABELS: Record = { "messages.inbound.debounceMs": "Inbound Message Debounce (ms)", "messages.inbound.byChannel": "Inbound Debounce by Channel (ms)", "messages.tts": "Message Text-to-Speech", + "talk.provider": "Talk Active Provider", + "talk.providers": "Talk Provider Settings", + "talk.providers.*.voiceId": "Talk Provider Voice ID", + "talk.providers.*.voiceAliases": "Talk Provider Voice Aliases", + "talk.providers.*.modelId": "Talk Provider Model ID", + "talk.providers.*.outputFormat": "Talk Provider Output Format", + "talk.providers.*.apiKey": "Talk Provider API Key", "talk.apiKey": "Talk API Key", channels: "Channels", "channels.defaults": "Channel Defaults", diff --git a/src/config/talk.normalize.test.ts b/src/config/talk.normalize.test.ts new file mode 100644 index 000000000000..a61af099bf31 --- /dev/null +++ b/src/config/talk.normalize.test.ts @@ -0,0 +1,150 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { createConfigIO } from "./io.js"; +import { normalizeTalkSection } from "./talk.js"; + +async function withTempConfig( + config: unknown, + run: (configPath: string) => Promise, +): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-talk-")); + const configPath = path.join(dir, "openclaw.json"); + await fs.writeFile(configPath, JSON.stringify(config, null, 2)); + try { + await run(configPath); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +async function withEnv( + updates: Record, + run: () => Promise, +): Promise { + const previous = new Map(); + for (const [key, value] of Object.entries(updates)) { + previous.set(key, process.env[key]); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + try { + await run(); + } finally { + for (const [key, value] of previous.entries()) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +describe("talk normalization", () => { + it("maps legacy ElevenLabs fields into provider/providers", () => { + const normalized = normalizeTalkSection({ + voiceId: "voice-123", + voiceAliases: { Clawd: "EXAVITQu4vr4xnSDxMaL" }, + modelId: "eleven_v3", + outputFormat: "pcm_44100", + apiKey: "secret-key", + interruptOnSpeech: false, + }); + + expect(normalized).toEqual({ + provider: "elevenlabs", + providers: { + elevenlabs: { + voiceId: "voice-123", + voiceAliases: { Clawd: "EXAVITQu4vr4xnSDxMaL" }, + modelId: "eleven_v3", + outputFormat: "pcm_44100", + apiKey: "secret-key", + }, + }, + voiceId: "voice-123", + voiceAliases: { Clawd: "EXAVITQu4vr4xnSDxMaL" }, + modelId: "eleven_v3", + outputFormat: "pcm_44100", + apiKey: "secret-key", + interruptOnSpeech: false, + }); + }); + + it("uses new provider/providers shape directly when present", () => { + const normalized = normalizeTalkSection({ + provider: "acme", + providers: { + acme: { + voiceId: "acme-voice", + custom: true, + }, + }, + voiceId: "legacy-voice", + interruptOnSpeech: true, + }); + + expect(normalized).toEqual({ + provider: "acme", + providers: { + acme: { + voiceId: "acme-voice", + custom: true, + }, + }, + voiceId: "legacy-voice", + interruptOnSpeech: true, + }); + }); + + it("merges ELEVENLABS_API_KEY into normalized defaults for legacy configs", async () => { + await withEnv({ ELEVENLABS_API_KEY: "env-eleven-key" }, async () => { + await withTempConfig( + { + talk: { + voiceId: "voice-123", + }, + }, + async (configPath) => { + const io = createConfigIO({ configPath }); + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.config.talk?.provider).toBe("elevenlabs"); + expect(snapshot.config.talk?.providers?.elevenlabs?.voiceId).toBe("voice-123"); + expect(snapshot.config.talk?.providers?.elevenlabs?.apiKey).toBe("env-eleven-key"); + expect(snapshot.config.talk?.apiKey).toBe("env-eleven-key"); + }, + ); + }); + }); + + it("does not apply ELEVENLABS_API_KEY when active provider is not elevenlabs", async () => { + await withEnv({ ELEVENLABS_API_KEY: "env-eleven-key" }, async () => { + await withTempConfig( + { + talk: { + provider: "acme", + providers: { + acme: { + voiceId: "acme-voice", + }, + }, + }, + }, + async (configPath) => { + const io = createConfigIO({ configPath }); + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.config.talk?.provider).toBe("acme"); + expect(snapshot.config.talk?.providers?.acme?.voiceId).toBe("acme-voice"); + expect(snapshot.config.talk?.providers?.acme?.apiKey).toBeUndefined(); + expect(snapshot.config.talk?.apiKey).toBeUndefined(); + }, + ); + }); + }); +}); diff --git a/src/config/talk.ts b/src/config/talk.ts index f7856dc67965..e8de2e398019 100644 --- a/src/config/talk.ts +++ b/src/config/talk.ts @@ -1,6 +1,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { TalkConfig, TalkProviderConfig } from "./types.gateway.js"; +import type { OpenClawConfig } from "./types.js"; type TalkApiKeyDeps = { fs?: typeof fs; @@ -8,6 +10,266 @@ type TalkApiKeyDeps = { path?: typeof path; }; +export const DEFAULT_TALK_PROVIDER = "elevenlabs"; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeVoiceAliases(value: unknown): Record | undefined { + if (!isPlainObject(value)) { + return undefined; + } + const aliases: Record = {}; + for (const [alias, rawId] of Object.entries(value)) { + if (typeof rawId !== "string") { + continue; + } + aliases[alias] = rawId; + } + return Object.keys(aliases).length > 0 ? aliases : undefined; +} + +function normalizeTalkProviderConfig(value: unknown): TalkProviderConfig | undefined { + if (!isPlainObject(value)) { + return undefined; + } + + const provider: TalkProviderConfig = {}; + for (const [key, raw] of Object.entries(value)) { + if (raw === undefined) { + continue; + } + if (key === "voiceAliases") { + const aliases = normalizeVoiceAliases(raw); + if (aliases) { + provider.voiceAliases = aliases; + } + continue; + } + if (key === "voiceId" || key === "modelId" || key === "outputFormat" || key === "apiKey") { + const normalized = normalizeString(raw); + if (normalized) { + provider[key] = normalized; + } + continue; + } + provider[key] = raw; + } + + return Object.keys(provider).length > 0 ? provider : undefined; +} + +function normalizeTalkProviders(value: unknown): Record | undefined { + if (!isPlainObject(value)) { + return undefined; + } + const providers: Record = {}; + for (const [rawProviderId, providerConfig] of Object.entries(value)) { + const providerId = normalizeString(rawProviderId); + if (!providerId) { + continue; + } + const normalizedProvider = normalizeTalkProviderConfig(providerConfig); + if (!normalizedProvider) { + continue; + } + providers[providerId] = normalizedProvider; + } + return Object.keys(providers).length > 0 ? providers : undefined; +} + +function normalizedLegacyTalkFields(source: Record): Partial { + const legacy: Partial = {}; + const voiceId = normalizeString(source.voiceId); + if (voiceId) { + legacy.voiceId = voiceId; + } + const voiceAliases = normalizeVoiceAliases(source.voiceAliases); + if (voiceAliases) { + legacy.voiceAliases = voiceAliases; + } + const modelId = normalizeString(source.modelId); + if (modelId) { + legacy.modelId = modelId; + } + const outputFormat = normalizeString(source.outputFormat); + if (outputFormat) { + legacy.outputFormat = outputFormat; + } + const apiKey = normalizeString(source.apiKey); + if (apiKey) { + legacy.apiKey = apiKey; + } + return legacy; +} + +function legacyProviderConfigFromTalk( + source: Record, +): TalkProviderConfig | undefined { + return normalizeTalkProviderConfig({ + voiceId: source.voiceId, + voiceAliases: source.voiceAliases, + modelId: source.modelId, + outputFormat: source.outputFormat, + apiKey: source.apiKey, + }); +} + +function activeProviderFromTalk(talk: TalkConfig): string | undefined { + const provider = normalizeString(talk.provider); + if (provider) { + return provider; + } + const providerIds = talk.providers ? Object.keys(talk.providers) : []; + return providerIds.length === 1 ? providerIds[0] : undefined; +} + +function legacyTalkFieldsFromProviderConfig( + config: TalkProviderConfig | undefined, +): Partial { + if (!config) { + return {}; + } + const legacy: Partial = {}; + if (typeof config.voiceId === "string") { + legacy.voiceId = config.voiceId; + } + if ( + config.voiceAliases && + typeof config.voiceAliases === "object" && + !Array.isArray(config.voiceAliases) + ) { + const aliases = normalizeVoiceAliases(config.voiceAliases); + if (aliases) { + legacy.voiceAliases = aliases; + } + } + if (typeof config.modelId === "string") { + legacy.modelId = config.modelId; + } + if (typeof config.outputFormat === "string") { + legacy.outputFormat = config.outputFormat; + } + if (typeof config.apiKey === "string") { + legacy.apiKey = config.apiKey; + } + return legacy; +} + +export function normalizeTalkSection(value: TalkConfig | undefined): TalkConfig | undefined { + if (!isPlainObject(value)) { + return undefined; + } + + const source = value as Record; + const hasNormalizedShape = typeof source.provider === "string" || isPlainObject(source.providers); + const normalized: TalkConfig = {}; + const legacy = normalizedLegacyTalkFields(source); + if (Object.keys(legacy).length > 0) { + Object.assign(normalized, legacy); + } + if (typeof source.interruptOnSpeech === "boolean") { + normalized.interruptOnSpeech = source.interruptOnSpeech; + } + + if (hasNormalizedShape) { + const providers = normalizeTalkProviders(source.providers); + const provider = normalizeString(source.provider); + if (providers) { + normalized.providers = providers; + } + if (provider) { + normalized.provider = provider; + } else if (providers) { + const ids = Object.keys(providers); + if (ids.length === 1) { + normalized.provider = ids[0]; + } + } + return Object.keys(normalized).length > 0 ? normalized : undefined; + } + + const legacyProviderConfig = legacyProviderConfigFromTalk(source); + if (legacyProviderConfig) { + normalized.provider = DEFAULT_TALK_PROVIDER; + normalized.providers = { [DEFAULT_TALK_PROVIDER]: legacyProviderConfig }; + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function normalizeTalkConfig(config: OpenClawConfig): OpenClawConfig { + if (!config.talk) { + return config; + } + const normalizedTalk = normalizeTalkSection(config.talk); + if (!normalizedTalk) { + return config; + } + return { + ...config, + talk: normalizedTalk, + }; +} + +export function resolveActiveTalkProviderConfig(talk: TalkConfig | undefined): { + provider?: string; + config?: TalkProviderConfig; +} { + const normalizedTalk = normalizeTalkSection(talk); + if (!normalizedTalk) { + return {}; + } + const provider = activeProviderFromTalk(normalizedTalk); + if (!provider) { + return {}; + } + return { + provider, + config: normalizedTalk.providers?.[provider], + }; +} + +export function buildTalkConfigResponse(value: unknown): TalkConfig | undefined { + if (!isPlainObject(value)) { + return undefined; + } + const normalized = normalizeTalkSection(value as TalkConfig); + if (!normalized) { + return undefined; + } + + const payload: TalkConfig = {}; + if (typeof normalized.interruptOnSpeech === "boolean") { + payload.interruptOnSpeech = normalized.interruptOnSpeech; + } + if (normalized.providers && Object.keys(normalized.providers).length > 0) { + payload.providers = normalized.providers; + } + if (typeof normalized.provider === "string") { + payload.provider = normalized.provider; + } + + const activeProvider = activeProviderFromTalk(normalized); + const providerConfig = activeProvider ? normalized.providers?.[activeProvider] : undefined; + const providerCompatibilityLegacy = legacyTalkFieldsFromProviderConfig(providerConfig); + const compatibilityLegacy = + Object.keys(providerCompatibilityLegacy).length > 0 + ? providerCompatibilityLegacy + : normalizedLegacyTalkFields(normalized as unknown as Record); + Object.assign(payload, compatibilityLegacy); + + return Object.keys(payload).length > 0 ? payload : undefined; +} + export function readTalkApiKeyFromProfile(deps: TalkApiKeyDeps = {}): string | null { const fsImpl = deps.fs ?? fs; const osImpl = deps.os ?? os; diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 5a18da096786..5e644db40eb4 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -46,19 +46,38 @@ export type CanvasHostConfig = { liveReload?: boolean; }; -export type TalkConfig = { - /** Default ElevenLabs voice ID for Talk mode. */ +export type TalkProviderConfig = { + /** Default voice ID for the provider's Talk mode implementation. */ voiceId?: string; - /** Optional voice name -> ElevenLabs voice ID map. */ + /** Optional voice name -> provider voice ID map. */ voiceAliases?: Record; - /** Default ElevenLabs model ID for Talk mode. */ + /** Default provider model ID for Talk mode. */ modelId?: string; - /** Default ElevenLabs output format (e.g. mp3_44100_128). */ + /** Default provider output format (for example pcm_44100). */ outputFormat?: string; - /** ElevenLabs API key (optional; falls back to ELEVENLABS_API_KEY). */ + /** Provider API key (optional; provider-specific env fallback may apply). */ apiKey?: string; + /** Provider-specific extensions. */ + [key: string]: unknown; +}; + +export type TalkConfig = { + /** Active Talk TTS provider (for example "elevenlabs"). */ + provider?: string; + /** Provider-specific Talk config keyed by provider id. */ + providers?: Record; /** Stop speaking when user starts talking (default: true). */ interruptOnSpeech?: boolean; + + /** + * Legacy ElevenLabs compatibility fields. + * Kept during rollout while older clients migrate to provider/providers. + */ + voiceId?: string; + voiceAliases?: Record; + modelId?: string; + outputFormat?: string; + apiKey?: string; }; export type GatewayControlUiConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index dd6b1b1c1d0d..6ea3bd002879 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -439,6 +439,21 @@ export const OpenClawSchema = z .optional(), talk: z .object({ + provider: z.string().optional(), + providers: z + .record( + z.string(), + z + .object({ + voiceId: z.string().optional(), + voiceAliases: z.record(z.string(), z.string()).optional(), + modelId: z.string().optional(), + outputFormat: z.string().optional(), + apiKey: z.string().optional().register(sensitive), + }) + .catchall(z.unknown()), + ) + .optional(), voiceId: z.string().optional(), voiceAliases: z.record(z.string(), z.string()).optional(), modelId: z.string().optional(), diff --git a/src/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index 7d8642098887..51f5194cc839 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -16,6 +16,17 @@ export const TalkConfigParamsSchema = Type.Object( { additionalProperties: false }, ); +const TalkProviderConfigSchema = Type.Object( + { + voiceId: Type.Optional(Type.String()), + voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())), + modelId: Type.Optional(Type.String()), + outputFormat: Type.Optional(Type.String()), + apiKey: Type.Optional(Type.String()), + }, + { additionalProperties: true }, +); + export const TalkConfigResultSchema = Type.Object( { config: Type.Object( @@ -23,6 +34,8 @@ export const TalkConfigResultSchema = Type.Object( talk: Type.Optional( Type.Object( { + provider: Type.Optional(Type.String()), + providers: Type.Optional(Type.Record(Type.String(), TalkProviderConfigSchema)), voiceId: Type.Optional(Type.String()), voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())), modelId: Type.Optional(Type.String()), diff --git a/src/gateway/server-methods/talk.ts b/src/gateway/server-methods/talk.ts index 760f4cc93106..693f34475379 100644 --- a/src/gateway/server-methods/talk.ts +++ b/src/gateway/server-methods/talk.ts @@ -1,5 +1,6 @@ import { readConfigFileSnapshot } from "../../config/config.js"; import { redactConfigObject } from "../../config/redact-snapshot.js"; +import { buildTalkConfigResponse } from "../../config/talk.js"; import { ErrorCodes, errorShape, @@ -17,46 +18,6 @@ function canReadTalkSecrets(client: { connect?: { scopes?: string[] } } | null): return scopes.includes(ADMIN_SCOPE) || scopes.includes(TALK_SECRETS_SCOPE); } -function normalizeTalkConfigSection(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - const source = value as Record; - const talk: Record = {}; - if (typeof source.voiceId === "string") { - talk.voiceId = source.voiceId; - } - if ( - source.voiceAliases && - typeof source.voiceAliases === "object" && - !Array.isArray(source.voiceAliases) - ) { - const aliases: Record = {}; - for (const [alias, id] of Object.entries(source.voiceAliases as Record)) { - if (typeof id !== "string") { - continue; - } - aliases[alias] = id; - } - if (Object.keys(aliases).length > 0) { - talk.voiceAliases = aliases; - } - } - if (typeof source.modelId === "string") { - talk.modelId = source.modelId; - } - if (typeof source.outputFormat === "string") { - talk.outputFormat = source.outputFormat; - } - if (typeof source.apiKey === "string") { - talk.apiKey = source.apiKey; - } - if (typeof source.interruptOnSpeech === "boolean") { - talk.interruptOnSpeech = source.interruptOnSpeech; - } - return Object.keys(talk).length > 0 ? talk : undefined; -} - export const talkHandlers: GatewayRequestHandlers = { "talk.config": async ({ params, respond, client }) => { if (!validateTalkConfigParams(params)) { @@ -87,7 +48,7 @@ export const talkHandlers: GatewayRequestHandlers = { const talkSource = includeSecrets ? snapshot.config.talk : redactConfigObject(snapshot.config.talk); - const talk = normalizeTalkConfigSection(talkSource); + const talk = buildTalkConfigResponse(talkSource); if (talk) { configPayload.talk = talk; } diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index 856e54ecebda..107d8a832635 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -79,12 +79,24 @@ describe("gateway talk.config", () => { await withServer(async (ws) => { await connectOperator(ws, ["operator.read"]); - const res = await rpcReq<{ config?: { talk?: { apiKey?: string; voiceId?: string } } }>( - ws, - "talk.config", - {}, - ); + const res = await rpcReq<{ + config?: { + talk?: { + provider?: string; + providers?: { + elevenlabs?: { voiceId?: string; apiKey?: string }; + }; + apiKey?: string; + voiceId?: string; + }; + }; + }>(ws, "talk.config", {}); expect(res.ok).toBe(true); + expect(res.payload?.config?.talk?.provider).toBe("elevenlabs"); + expect(res.payload?.config?.talk?.providers?.elevenlabs?.voiceId).toBe("voice-123"); + expect(res.payload?.config?.talk?.providers?.elevenlabs?.apiKey).toBe( + "__OPENCLAW_REDACTED__", + ); expect(res.payload?.config?.talk?.voiceId).toBe("voice-123"); expect(res.payload?.config?.talk?.apiKey).toBe("__OPENCLAW_REDACTED__"); }); @@ -113,4 +125,38 @@ describe("gateway talk.config", () => { expect(res.payload?.config?.talk?.apiKey).toBe("secret-key-abc"); }); }); + + it("prefers normalized provider payload over conflicting legacy talk keys", async () => { + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + voiceId: "voice-normalized", + }, + }, + voiceId: "voice-legacy", + }, + }); + + await withServer(async (ws) => { + await connectOperator(ws, ["operator.read"]); + const res = await rpcReq<{ + config?: { + talk?: { + provider?: string; + providers?: { + elevenlabs?: { voiceId?: string }; + }; + voiceId?: string; + }; + }; + }>(ws, "talk.config", {}); + expect(res.ok).toBe(true); + expect(res.payload?.config?.talk?.provider).toBe("elevenlabs"); + expect(res.payload?.config?.talk?.providers?.elevenlabs?.voiceId).toBe("voice-normalized"); + expect(res.payload?.config?.talk?.voiceId).toBe("voice-normalized"); + }); + }); }); From 44162055a85622533afd3ee060d7e5360cd952f2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:51:06 +0000 Subject: [PATCH 198/408] fix(config): dedupe talk schema help keys --- src/config/schema.help.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 8bc07121e3d1..4c699b19e106 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -293,18 +293,6 @@ export const FIELD_HELP: Record = { "canvasHost.liveReload": "Enables automatic live-reload behavior for canvas assets during development workflows. Keep disabled in production-like environments where deterministic output is preferred.", talk: "Talk-mode voice synthesis settings for voice identity, model selection, output format, and interruption behavior. Use this section to tune human-facing voice UX while controlling latency and cost.", - "talk.voiceId": - "Primary voice identifier used by talk mode when synthesizing spoken responses. Use a stable voice for consistent persona and switch only when experience goals change.", - "talk.voiceAliases": - "Alias map for human-friendly voice shortcuts to concrete voice IDs in talk workflows. Use aliases to simplify operator switching without exposing long provider-native IDs.", - "talk.modelId": - "Model override used for talk pipeline generation when voice workflows require different model behavior. Use this when speech output needs a specialized low-latency or style-tuned model.", - "talk.outputFormat": - "Audio output format for synthesized talk responses, depending on provider support and client playback expectations. Use formats compatible with your playback channel to avoid decode failures.", - "talk.interruptOnSpeech": - "When true, interrupts current speech playback on new speech/input events for more conversational turn-taking. Keep enabled for interactive voice UX and disable for uninterrupted long-form playback.", - "talk.apiKey": - "Optional talk-provider API key override used specifically for speech synthesis requests. Use env-backed secrets and set this only when talk traffic must use separate credentials.", "gateway.auth.token": "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", "gateway.auth.password": "Required for Tailscale funnel.", From 0e155690be0aff1682363100754004995e57c6ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:59:28 +0000 Subject: [PATCH 199/408] fix(config): add operational guidance to legacy talk help Co-authored-by: Nimrod Gutman --- src/config/schema.help.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 4c699b19e106..a0d464274fd7 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -144,15 +144,15 @@ export const FIELD_HELP: Record = { "talk.voiceId": "Legacy ElevenLabs default voice ID for Talk mode. Prefer talk.providers.elevenlabs.voiceId.", "talk.voiceAliases": - 'Legacy ElevenLabs voice alias map (for example {"Clawd":"EXAVITQu4vr4xnSDxMaL"}). Prefer talk.providers.elevenlabs.voiceAliases.', + 'Use this legacy ElevenLabs voice alias map (for example {"Clawd":"EXAVITQu4vr4xnSDxMaL"}) only during migration. Prefer talk.providers.elevenlabs.voiceAliases.', "talk.modelId": "Legacy ElevenLabs model ID for Talk mode (default: eleven_v3). Prefer talk.providers.elevenlabs.modelId.", "talk.outputFormat": - "Legacy ElevenLabs output format for Talk mode (for example pcm_44100 or mp3_44100_128). Prefer talk.providers.elevenlabs.outputFormat.", + "Use this legacy ElevenLabs output format for Talk mode (for example pcm_44100 or mp3_44100_128) only during migration. Prefer talk.providers.elevenlabs.outputFormat.", "talk.apiKey": - "Legacy ElevenLabs API key for Talk mode. Prefer talk.providers.elevenlabs.apiKey (fallback: ELEVENLABS_API_KEY).", + "Use this legacy ElevenLabs API key for Talk mode only during migration, and keep secrets in env-backed storage. Prefer talk.providers.elevenlabs.apiKey (fallback: ELEVENLABS_API_KEY).", "talk.interruptOnSpeech": - "If true (default), stop assistant speech when the user starts speaking in Talk mode.", + "If true (default), stop assistant speech when the user starts speaking in Talk mode. Keep enabled for conversational turn-taking.", "agents.list.*.skills": "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", "agents.list[].skills": From 13bfe7faa68c43b07711fd15471c85a8925f6914 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 15:04:40 +0000 Subject: [PATCH 200/408] refactor(sandbox): share bind parsing and host-path policy checks --- src/agents/sandbox/bind-spec.test.ts | 29 +++++ src/agents/sandbox/bind-spec.ts | 34 ++++++ src/agents/sandbox/fs-paths.ts | 43 +------ src/agents/sandbox/host-paths.test.ts | 38 ++++++ src/agents/sandbox/host-paths.ts | 47 ++++++++ .../sandbox/validate-sandbox-security.ts | 112 +++++++----------- 6 files changed, 196 insertions(+), 107 deletions(-) create mode 100644 src/agents/sandbox/bind-spec.test.ts create mode 100644 src/agents/sandbox/bind-spec.ts create mode 100644 src/agents/sandbox/host-paths.test.ts create mode 100644 src/agents/sandbox/host-paths.ts diff --git a/src/agents/sandbox/bind-spec.test.ts b/src/agents/sandbox/bind-spec.test.ts new file mode 100644 index 000000000000..30d86551cc4f --- /dev/null +++ b/src/agents/sandbox/bind-spec.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { splitSandboxBindSpec } from "./bind-spec.js"; + +describe("splitSandboxBindSpec", () => { + it("splits POSIX bind specs with and without mode", () => { + expect(splitSandboxBindSpec("/tmp/a:/workspace-a:ro")).toEqual({ + host: "/tmp/a", + container: "/workspace-a", + options: "ro", + }); + expect(splitSandboxBindSpec("/tmp/b:/workspace-b")).toEqual({ + host: "/tmp/b", + container: "/workspace-b", + options: "", + }); + }); + + it("preserves Windows drive-letter host paths", () => { + expect(splitSandboxBindSpec("C:\\Users\\kai\\workspace:/workspace:ro")).toEqual({ + host: "C:\\Users\\kai\\workspace", + container: "/workspace", + options: "ro", + }); + }); + + it("returns null when no host/container separator exists", () => { + expect(splitSandboxBindSpec("/tmp/no-separator")).toBeNull(); + }); +}); diff --git a/src/agents/sandbox/bind-spec.ts b/src/agents/sandbox/bind-spec.ts new file mode 100644 index 000000000000..4ce53c251a47 --- /dev/null +++ b/src/agents/sandbox/bind-spec.ts @@ -0,0 +1,34 @@ +type SplitBindSpec = { + host: string; + container: string; + options: string; +}; + +export function splitSandboxBindSpec(spec: string): SplitBindSpec | null { + const separator = getHostContainerSeparatorIndex(spec); + if (separator === -1) { + return null; + } + + const host = spec.slice(0, separator); + const rest = spec.slice(separator + 1); + const optionsStart = rest.indexOf(":"); + if (optionsStart === -1) { + return { host, container: rest, options: "" }; + } + return { + host, + container: rest.slice(0, optionsStart), + options: rest.slice(optionsStart + 1), + }; +} + +function getHostContainerSeparatorIndex(spec: string): number { + const hasDriveLetterPrefix = /^[A-Za-z]:[\\/]/.test(spec); + for (let i = hasDriveLetterPrefix ? 2 : 0; i < spec.length; i += 1) { + if (spec[i] === ":") { + return i; + } + } + return -1; +} diff --git a/src/agents/sandbox/fs-paths.ts b/src/agents/sandbox/fs-paths.ts index 11b5d7120403..5de2f9e12eeb 100644 --- a/src/agents/sandbox/fs-paths.ts +++ b/src/agents/sandbox/fs-paths.ts @@ -1,6 +1,8 @@ import path from "node:path"; import { resolveSandboxInputPath, resolveSandboxPath } from "../sandbox-paths.js"; +import { splitSandboxBindSpec } from "./bind-spec.js"; import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; +import { resolveSandboxHostPathViaExistingAncestor } from "./host-paths.js"; import type { SandboxContext } from "./types.js"; export type SandboxFsMount = { @@ -23,19 +25,13 @@ type ParsedBindMount = { writable: boolean; }; -type SplitBindSpec = { - host: string; - container: string; - options: string; -}; - export function parseSandboxBindMount(spec: string): ParsedBindMount | null { const trimmed = spec.trim(); if (!trimmed) { return null; } - const parsed = splitBindSpec(trimmed); + const parsed = splitSandboxBindSpec(trimmed); if (!parsed) { return null; } @@ -60,35 +56,6 @@ export function parseSandboxBindMount(spec: string): ParsedBindMount | null { }; } -function splitBindSpec(spec: string): SplitBindSpec | null { - const separator = getHostContainerSeparatorIndex(spec); - if (separator === -1) { - return null; - } - - const host = spec.slice(0, separator); - const rest = spec.slice(separator + 1); - const optionsStart = rest.indexOf(":"); - if (optionsStart === -1) { - return { host, container: rest, options: "" }; - } - return { - host, - container: rest.slice(0, optionsStart), - options: rest.slice(optionsStart + 1), - }; -} - -function getHostContainerSeparatorIndex(spec: string): number { - const hasDriveLetterPrefix = /^[A-Za-z]:[\\/]/.test(spec); - for (let i = hasDriveLetterPrefix ? 2 : 0; i < spec.length; i += 1) { - if (spec[i] === ":") { - return i; - } - } - return -1; -} - export function buildSandboxFsMounts(sandbox: SandboxContext): SandboxFsMount[] { const mounts: SandboxFsMount[] = [ { @@ -259,7 +226,9 @@ function isPathInsidePosix(root: string, target: string): boolean { } function isPathInsideHost(root: string, target: string): boolean { - const rel = path.relative(root, target); + const canonicalRoot = resolveSandboxHostPathViaExistingAncestor(path.resolve(root)); + const canonicalTarget = resolveSandboxHostPathViaExistingAncestor(path.resolve(target)); + const rel = path.relative(canonicalRoot, canonicalTarget); if (!rel) { return true; } diff --git a/src/agents/sandbox/host-paths.test.ts b/src/agents/sandbox/host-paths.test.ts new file mode 100644 index 000000000000..30933a5e03e0 --- /dev/null +++ b/src/agents/sandbox/host-paths.test.ts @@ -0,0 +1,38 @@ +import { mkdtempSync, mkdirSync, realpathSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + normalizeSandboxHostPath, + resolveSandboxHostPathViaExistingAncestor, +} from "./host-paths.js"; + +describe("normalizeSandboxHostPath", () => { + it("normalizes dot segments and strips trailing slash", () => { + expect(normalizeSandboxHostPath("/tmp/a/../b//")).toBe("/tmp/b"); + }); +}); + +describe("resolveSandboxHostPathViaExistingAncestor", () => { + it("keeps non-absolute paths unchanged", () => { + expect(resolveSandboxHostPathViaExistingAncestor("relative/path")).toBe("relative/path"); + }); + + it("resolves symlink parents when the final leaf does not exist", () => { + if (process.platform === "win32") { + return; + } + + const root = mkdtempSync(join(tmpdir(), "openclaw-host-paths-")); + const workspace = join(root, "workspace"); + const outside = join(root, "outside"); + mkdirSync(workspace, { recursive: true }); + mkdirSync(outside, { recursive: true }); + const link = join(workspace, "alias-out"); + symlinkSync(outside, link); + + const unresolved = join(link, "missing-leaf"); + const resolved = resolveSandboxHostPathViaExistingAncestor(unresolved); + expect(resolved).toBe(join(realpathSync.native(outside), "missing-leaf")); + }); +}); diff --git a/src/agents/sandbox/host-paths.ts b/src/agents/sandbox/host-paths.ts new file mode 100644 index 000000000000..7b99ed0a53cb --- /dev/null +++ b/src/agents/sandbox/host-paths.ts @@ -0,0 +1,47 @@ +import { existsSync, realpathSync } from "node:fs"; +import { posix } from "node:path"; + +/** + * Normalize a POSIX host path: resolve `.`, `..`, collapse `//`, strip trailing `/`. + */ +export function normalizeSandboxHostPath(raw: string): string { + const trimmed = raw.trim(); + return posix.normalize(trimmed).replace(/\/+$/, "") || "/"; +} + +/** + * Resolve a path through the deepest existing ancestor so parent symlinks are honored + * even when the final source leaf does not exist yet. + */ +export function resolveSandboxHostPathViaExistingAncestor(sourcePath: string): string { + if (!sourcePath.startsWith("/")) { + return sourcePath; + } + + const normalized = normalizeSandboxHostPath(sourcePath); + let current = normalized; + const missingSegments: string[] = []; + + while (current !== "/" && !existsSync(current)) { + missingSegments.unshift(posix.basename(current)); + const parent = posix.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + if (!existsSync(current)) { + return normalized; + } + + try { + const resolvedAncestor = normalizeSandboxHostPath(realpathSync.native(current)); + if (missingSegments.length === 0) { + return resolvedAncestor; + } + return normalizeSandboxHostPath(posix.join(resolvedAncestor, ...missingSegments)); + } catch { + return normalized; + } +} diff --git a/src/agents/sandbox/validate-sandbox-security.ts b/src/agents/sandbox/validate-sandbox-security.ts index 44fe9f7ba0da..393d9f4b3361 100644 --- a/src/agents/sandbox/validate-sandbox-security.ts +++ b/src/agents/sandbox/validate-sandbox-security.ts @@ -5,9 +5,12 @@ * Enforced at runtime when creating sandbox containers. */ -import { existsSync, realpathSync } from "node:fs"; -import { posix } from "node:path"; +import { splitSandboxBindSpec } from "./bind-spec.js"; import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; +import { + normalizeSandboxHostPath, + resolveSandboxHostPathViaExistingAncestor, +} from "./host-paths.js"; // Targeted denylist: host paths that should never be exposed inside sandbox containers. // Exported for reuse in security audit collectors. @@ -53,20 +56,11 @@ type ParsedBindSpec = { function parseBindSpec(bind: string): ParsedBindSpec { const trimmed = bind.trim(); - const firstColon = trimmed.indexOf(":"); - if (firstColon <= 0) { + const parsed = splitSandboxBindSpec(trimmed); + if (!parsed) { return { source: trimmed, target: "" }; } - const source = trimmed.slice(0, firstColon); - const rest = trimmed.slice(firstColon + 1); - const secondColon = rest.indexOf(":"); - if (secondColon === -1) { - return { source, target: rest }; - } - return { - source, - target: rest.slice(0, secondColon), - }; + return { source: parsed.host, target: parsed.container }; } /** @@ -85,8 +79,7 @@ export function parseBindTargetPath(bind: string): string { * Normalize a POSIX path: resolve `.`, `..`, collapse `//`, strip trailing `/`. */ export function normalizeHostPath(raw: string): string { - const trimmed = raw.trim(); - return posix.normalize(trimmed).replace(/\/+$/, "") || "/"; + return normalizeSandboxHostPath(raw); } /** @@ -119,41 +112,6 @@ export function getBlockedReasonForSourcePath(sourceNormalized: string): Blocked return null; } -function resolvePathViaExistingAncestor(sourcePath: string): string { - if (!sourcePath.startsWith("/")) { - return sourcePath; - } - - const normalized = normalizeHostPath(sourcePath); - let current = normalized; - const missingSegments: string[] = []; - - // Resolve through the deepest existing ancestor so symlink parents are honored - // even when the final source leaf does not exist yet. - while (current !== "/" && !existsSync(current)) { - missingSegments.unshift(posix.basename(current)); - const parent = posix.dirname(current); - if (parent === current) { - break; - } - current = parent; - } - - if (!existsSync(current)) { - return normalized; - } - - try { - const resolvedAncestor = normalizeHostPath(realpathSync.native(current)); - if (missingSegments.length === 0) { - return resolvedAncestor; - } - return normalizeHostPath(posix.join(resolvedAncestor, ...missingSegments)); - } catch { - return normalized; - } -} - function normalizeAllowedRoots(roots: string[] | undefined): string[] { if (!roots?.length) { return []; @@ -165,7 +123,7 @@ function normalizeAllowedRoots(roots: string[] | undefined): string[] { const expanded = new Set(); for (const root of normalized) { expanded.add(root); - const real = resolvePathViaExistingAncestor(root); + const real = resolveSandboxHostPathViaExistingAncestor(root); if (real !== root) { expanded.add(real); } @@ -217,6 +175,25 @@ function getReservedTargetReason(bind: string): BlockedBindReason | null { return null; } +function enforceSourcePathPolicy(params: { + bind: string; + sourcePath: string; + allowedRoots: string[]; + allowSourcesOutsideAllowedRoots: boolean; +}): void { + const blockedReason = getBlockedReasonForSourcePath(params.sourcePath); + if (blockedReason) { + throw formatBindBlockedError({ bind: params.bind, reason: blockedReason }); + } + if (params.allowSourcesOutsideAllowedRoots) { + return; + } + const allowedReason = getOutsideAllowedRootsReason(params.sourcePath, params.allowedRoots); + if (allowedReason) { + throw formatBindBlockedError({ bind: params.bind, reason: allowedReason }); + } +} + function formatBindBlockedError(params: { bind: string; reason: BlockedBindReason }): Error { if (params.reason.kind === "non_absolute") { return new Error( @@ -281,26 +258,21 @@ export function validateBindMounts( const sourceRaw = parseBindSourcePath(bind); const sourceNormalized = normalizeHostPath(sourceRaw); - - if (!options?.allowSourcesOutsideAllowedRoots) { - const allowedReason = getOutsideAllowedRootsReason(sourceNormalized, allowedRoots); - if (allowedReason) { - throw formatBindBlockedError({ bind, reason: allowedReason }); - } - } + enforceSourcePathPolicy({ + bind, + sourcePath: sourceNormalized, + allowedRoots, + allowSourcesOutsideAllowedRoots: options?.allowSourcesOutsideAllowedRoots === true, + }); // Symlink escape hardening: resolve through existing ancestors and re-check. - const sourceCanonical = resolvePathViaExistingAncestor(sourceNormalized); - const reason = getBlockedReasonForSourcePath(sourceCanonical); - if (reason) { - throw formatBindBlockedError({ bind, reason }); - } - if (!options?.allowSourcesOutsideAllowedRoots) { - const allowedReason = getOutsideAllowedRootsReason(sourceCanonical, allowedRoots); - if (allowedReason) { - throw formatBindBlockedError({ bind, reason: allowedReason }); - } - } + const sourceCanonical = resolveSandboxHostPathViaExistingAncestor(sourceNormalized); + enforceSourcePathPolicy({ + bind, + sourcePath: sourceCanonical, + allowedRoots, + allowSourcesOutsideAllowedRoots: options?.allowSourcesOutsideAllowedRoots === true, + }); } } From 6c5ab543c0b6b2644d18bc0d6ede5a6c1904f2df Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 15:05:09 +0000 Subject: [PATCH 201/408] refactor: tighten external-link policy and window.open guard --- scripts/check-no-raw-window-open.mjs | 85 +++++++++++++++---- test/scripts/check-no-raw-window-open.test.ts | 39 +++++++++ ui/src/ui/app-render.ts | 5 +- ui/src/ui/external-link.test.ts | 18 ++++ ui/src/ui/external-link.ts | 19 +++++ ui/src/ui/test-helpers/app-mount.ts | 3 +- .../ui/views/chat-image-open.browser.test.ts | 1 - ui/src/ui/views/overview.ts | 21 ++--- 8 files changed, 162 insertions(+), 29 deletions(-) create mode 100644 test/scripts/check-no-raw-window-open.test.ts create mode 100644 ui/src/ui/external-link.test.ts create mode 100644 ui/src/ui/external-link.ts diff --git a/scripts/check-no-raw-window-open.mjs b/scripts/check-no-raw-window-open.mjs index 55334549ba19..930bfe60a612 100644 --- a/scripts/check-no-raw-window-open.mjs +++ b/scripts/check-no-raw-window-open.mjs @@ -3,6 +3,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import ts from "typescript"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const uiSourceDir = path.join(repoRoot, "ui", "src", "ui"); @@ -39,20 +40,67 @@ async function collectTypeScriptFiles(dir) { return out; } -function lineNumberAt(content, index) { - let lines = 1; - for (let i = 0; i < index; i++) { - if (content.charCodeAt(i) === 10) { - lines++; +function unwrapExpression(expression) { + let current = expression; + while (true) { + if (ts.isParenthesizedExpression(current)) { + current = current.expression; + continue; + } + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + current = current.expression; + continue; } + if (ts.isNonNullExpression(current)) { + current = current.expression; + continue; + } + return current; + } +} + +function asPropertyAccess(expression) { + if (ts.isPropertyAccessExpression(expression)) { + return expression; + } + if (typeof ts.isPropertyAccessChain === "function" && ts.isPropertyAccessChain(expression)) { + return expression; + } + return null; +} + +function isRawWindowOpenCall(expression) { + const propertyAccess = asPropertyAccess(unwrapExpression(expression)); + if (!propertyAccess || propertyAccess.name.text !== "open") { + return false; } + + const receiver = unwrapExpression(propertyAccess.expression); + return ( + ts.isIdentifier(receiver) && (receiver.text === "window" || receiver.text === "globalThis") + ); +} + +export function findRawWindowOpenLines(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const lines = []; + + const visit = (node) => { + if (ts.isCallExpression(node) && isRawWindowOpenCall(node.expression)) { + const line = + sourceFile.getLineAndCharacterOfPosition(node.expression.getStart(sourceFile)).line + 1; + lines.push(line); + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); return lines; } -async function main() { +export async function main() { const files = await collectTypeScriptFiles(uiSourceDir); const violations = []; - const rawWindowOpenRe = /\bwindow\s*\.\s*open\s*\(/g; for (const filePath of files) { if (allowedCallsites.has(filePath)) { @@ -60,12 +108,9 @@ async function main() { } const content = await fs.readFile(filePath, "utf8"); - let match = rawWindowOpenRe.exec(content); - while (match) { - const line = lineNumberAt(content, match.index); + for (const line of findRawWindowOpenLines(content, filePath)) { const relPath = path.relative(repoRoot, filePath); violations.push(`${relPath}:${line}`); - match = rawWindowOpenRe.exec(content); } } @@ -81,7 +126,17 @@ async function main() { process.exit(1); } -main().catch((error) => { - console.error(error); - process.exit(1); -}); +const isDirectExecution = (() => { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === fileURLToPath(import.meta.url); +})(); + +if (isDirectExecution) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/test/scripts/check-no-raw-window-open.test.ts b/test/scripts/check-no-raw-window-open.test.ts new file mode 100644 index 000000000000..543c4b797935 --- /dev/null +++ b/test/scripts/check-no-raw-window-open.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { findRawWindowOpenLines } from "../../scripts/check-no-raw-window-open.mjs"; + +describe("check-no-raw-window-open", () => { + it("finds direct window.open calls", () => { + const source = ` + function openDocs() { + window.open("https://docs.openclaw.ai"); + } + `; + expect(findRawWindowOpenLines(source)).toEqual([3]); + }); + + it("finds globalThis.open calls", () => { + const source = ` + function openDocs() { + globalThis.open("https://docs.openclaw.ai"); + } + `; + expect(findRawWindowOpenLines(source)).toEqual([3]); + }); + + it("ignores mentions in strings and comments", () => { + const source = ` + // window.open("https://example.com") + const text = "window.open('https://example.com')"; + `; + expect(findRawWindowOpenLines(source)).toEqual([]); + }); + + it("handles parenthesized and asserted window references", () => { + const source = ` + const openRef = (window as Window).open; + openRef("https://example.com"); + (window as Window).open("https://example.com"); + `; + expect(findRawWindowOpenLines(source)).toEqual([4]); + }); +}); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 487ba0bbc538..55eeaedd7e0b 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -62,6 +62,7 @@ import { updateSkillEdit, updateSkillEnabled, } from "./controllers/skills.ts"; +import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts"; import { icons } from "./icons.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; import { renderAgents } from "./views/agents.ts"; @@ -289,8 +290,8 @@ export function renderApp(state: AppViewState) { diff --git a/ui/src/ui/external-link.test.ts b/ui/src/ui/external-link.test.ts new file mode 100644 index 000000000000..3c46c7faa30d --- /dev/null +++ b/ui/src/ui/external-link.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { buildExternalLinkRel } from "./external-link.ts"; + +describe("buildExternalLinkRel", () => { + it("always includes required security tokens", () => { + expect(buildExternalLinkRel()).toBe("noopener noreferrer"); + }); + + it("preserves extra rel tokens while deduping required ones", () => { + expect(buildExternalLinkRel("noreferrer nofollow NOOPENER")).toBe( + "noopener noreferrer nofollow", + ); + }); + + it("ignores whitespace-only rel input", () => { + expect(buildExternalLinkRel(" ")).toBe("noopener noreferrer"); + }); +}); diff --git a/ui/src/ui/external-link.ts b/ui/src/ui/external-link.ts new file mode 100644 index 000000000000..fc7ff5ef7e2e --- /dev/null +++ b/ui/src/ui/external-link.ts @@ -0,0 +1,19 @@ +const REQUIRED_EXTERNAL_REL_TOKENS = ["noopener", "noreferrer"] as const; + +export const EXTERNAL_LINK_TARGET = "_blank"; + +export function buildExternalLinkRel(currentRel?: string): string { + const extraTokens: string[] = []; + const seen = new Set(REQUIRED_EXTERNAL_REL_TOKENS); + + for (const rawToken of (currentRel ?? "").split(/\s+/)) { + const token = rawToken.trim().toLowerCase(); + if (!token || seen.has(token)) { + continue; + } + seen.add(token); + extraTokens.push(token); + } + + return [...REQUIRED_EXTERNAL_REL_TOKENS, ...extraTokens].join(" "); +} diff --git a/ui/src/ui/test-helpers/app-mount.ts b/ui/src/ui/test-helpers/app-mount.ts index f64c9da6dd6e..d6fda9475c42 100644 --- a/ui/src/ui/test-helpers/app-mount.ts +++ b/ui/src/ui/test-helpers/app-mount.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach } from "vitest"; -import { OpenClawApp } from "../app.ts"; +import "../app.ts"; +import type { OpenClawApp } from "../app.ts"; export function mountApp(pathname: string) { window.history.replaceState({}, "", pathname); diff --git a/ui/src/ui/views/chat-image-open.browser.test.ts b/ui/src/ui/views/chat-image-open.browser.test.ts index 768c5968100f..60e6df26554a 100644 --- a/ui/src/ui/views/chat-image-open.browser.test.ts +++ b/ui/src/ui/views/chat-image-open.browser.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import "../app.ts"; import { mountApp, registerAppMountHooks } from "../test-helpers/app-mount.ts"; registerAppMountHooks(); diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index 56889c0f6d59..3c341df473b4 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -1,6 +1,7 @@ import { html } from "lit"; import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js"; import { t, i18n, type Locale } from "../../i18n/index.ts"; +import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts"; import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts"; import type { GatewayHelloOk } from "../gateway.ts"; import { formatNextRun } from "../presenter.ts"; @@ -59,8 +60,8 @@ export function renderOverview(props: OverviewProps) { Docs: Device pairing @@ -116,8 +117,8 @@ export function renderOverview(props: OverviewProps) { Docs: Control UI auth @@ -132,8 +133,8 @@ export function renderOverview(props: OverviewProps) { Docs: Control UI auth @@ -171,8 +172,8 @@ export function renderOverview(props: OverviewProps) { Docs: Tailscale Serve @@ -180,8 +181,8 @@ export function renderOverview(props: OverviewProps) { Docs: Insecure HTTP From 878b4e0ed7cfc01caffc3ffeedaa6ccb94bfd978 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 15:13:59 +0000 Subject: [PATCH 202/408] refactor: unify tools.fs workspaceOnly resolution --- .../pi-embedded-runner/run/attempt.test.ts | 44 ++++++++++++++++ src/agents/pi-embedded-runner/run/attempt.ts | 21 ++++++-- src/agents/pi-embedded-runner/run/images.ts | 15 ++++-- src/agents/pi-tools.ts | 14 +----- src/agents/tool-fs-policy.test.ts | 50 +++++++++++++++++++ src/agents/tool-fs-policy.ts | 22 ++++++++ 6 files changed, 145 insertions(+), 21 deletions(-) create mode 100644 src/agents/tool-fs-policy.test.ts diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index ab25ce57e86d..97a881cf849c 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -1,8 +1,10 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../config/config.js"; import { injectHistoryImagesIntoMessages, + resolveAttemptFsWorkspaceOnly, resolvePromptBuildHookResult, resolvePromptModeForSession, } from "./attempt.js"; @@ -118,3 +120,45 @@ describe("resolvePromptModeForSession", () => { expect(resolvePromptModeForSession("agent:main:cron:job-1:run:run-abc")).toBe("full"); }); }); + +describe("resolveAttemptFsWorkspaceOnly", () => { + it("uses global tools.fs.workspaceOnly when agent has no override", () => { + const cfg: OpenClawConfig = { + tools: { + fs: { workspaceOnly: true }, + }, + }; + + expect( + resolveAttemptFsWorkspaceOnly({ + config: cfg, + sessionAgentId: "main", + }), + ).toBe(true); + }); + + it("prefers agent-specific tools.fs.workspaceOnly override", () => { + const cfg: OpenClawConfig = { + tools: { + fs: { workspaceOnly: true }, + }, + agents: { + list: [ + { + id: "main", + tools: { + fs: { workspaceOnly: false }, + }, + }, + ], + }, + }; + + expect( + resolveAttemptFsWorkspaceOnly({ + config: cfg, + sessionAgentId: "main", + }), + ).toBe(false); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index e05a21a5776a..25d8528fc48a 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -11,6 +11,7 @@ import { } from "@mariozechner/pi-coding-agent"; import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; +import type { OpenClawConfig } from "../../../config/config.js"; import { getMachineDisplayName } from "../../../infra/machine-name.js"; import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; @@ -28,7 +29,7 @@ import { resolveUserPath } from "../../../utils.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js"; import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; import { resolveOpenClawAgentDir } from "../../agent-paths.js"; -import { resolveAgentConfig, resolveSessionAgentIds } from "../../agent-scope.js"; +import { resolveSessionAgentIds } from "../../agent-scope.js"; import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js"; import { createCacheTrace } from "../../cache-trace.js"; @@ -74,6 +75,7 @@ import { import { buildSystemPromptParams } from "../../system-prompt-params.js"; import { buildSystemPromptReport } from "../../system-prompt-report.js"; import { sanitizeToolCallIdsForCloudCodeAssist } from "../../tool-call-id.js"; +import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js"; import { resolveTranscriptPolicy } from "../../transcript-policy.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js"; import { isRunnerAbortError } from "../abort.js"; @@ -228,6 +230,16 @@ export function resolvePromptModeForSession(sessionKey?: string): "minimal" | "f return isSubagentSessionKey(sessionKey) ? "minimal" : "full"; } +export function resolveAttemptFsWorkspaceOnly(params: { + config?: OpenClawConfig; + sessionAgentId: string; +}): boolean { + return resolveEffectiveToolFsWorkspaceOnly({ + cfg: params.config, + agentId: params.sessionAgentId, + }); +} + function summarizeMessagePayload(msg: AgentMessage): { textChars: number; imageBlocks: number } { const content = (msg as { content?: unknown }).content; if (typeof content === "string") { @@ -363,9 +375,10 @@ export async function runEmbeddedAttempt( config: params.config, agentId: params.agentId, }); - const effectiveFsWorkspaceOnly = - (resolveAgentConfig(params.config ?? {}, sessionAgentId)?.tools?.fs?.workspaceOnly ?? - params.config?.tools?.fs?.workspaceOnly) === true; + const effectiveFsWorkspaceOnly = resolveAttemptFsWorkspaceOnly({ + config: params.config, + sessionAgentId, + }); // Check if the model supports native image input const modelHasVision = params.model.input?.includes("image") ?? false; const toolsRaw = params.disableTools diff --git a/src/agents/pi-embedded-runner/run/images.ts b/src/agents/pi-embedded-runner/run/images.ts index 022950659e1c..897e8ca16e22 100644 --- a/src/agents/pi-embedded-runner/run/images.ts +++ b/src/agents/pi-embedded-runner/run/images.ts @@ -4,6 +4,7 @@ import type { ImageContent } from "@mariozechner/pi-ai"; import { resolveUserPath } from "../../../utils.js"; import { loadWebMedia } from "../../../web/media.js"; import type { ImageSanitizationLimits } from "../../image-sanitization.js"; +import { resolveSandboxedBridgeMediaPath } from "../../sandbox-media-paths.js"; import { assertSandboxPath } from "../../sandbox-paths.js"; import type { SandboxFsBridge } from "../../sandbox/fs-bridge.js"; import { sanitizeImageBlocks } from "../../tool-images.js"; @@ -199,11 +200,15 @@ export async function loadImageFromRef( if (ref.type === "path") { if (options?.sandbox) { try { - const resolved = options.sandbox.bridge.resolvePath({ - filePath: targetPath, - cwd: options.sandbox.root, + const resolved = await resolveSandboxedBridgeMediaPath({ + sandbox: { + root: options.sandbox.root, + bridge: options.sandbox.bridge, + workspaceOnly: options.workspaceOnly, + }, + mediaPath: targetPath, }); - targetPath = resolved.hostPath; + targetPath = resolved.resolved; } catch (err) { log.debug( `Native image: sandbox validation failed for ${ref.resolved}: ${err instanceof Error ? err.message : String(err)}`, @@ -213,7 +218,7 @@ export async function loadImageFromRef( } else if (!path.isAbsolute(targetPath)) { targetPath = path.resolve(workspaceDir, targetPath); } - if (options?.workspaceOnly) { + if (options?.workspaceOnly && !options?.sandbox) { const root = options?.sandbox?.root ?? workspaceDir; await assertSandboxPath({ filePath: targetPath, diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 7d9d5a4ff121..e2d29d375da3 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -49,7 +49,7 @@ import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.sc import type { AnyAgentTool } from "./pi-tools.types.js"; import type { SandboxContext } from "./sandbox.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; -import { createToolFsPolicy } from "./tool-fs-policy.js"; +import { createToolFsPolicy, resolveToolFsConfig } from "./tool-fs-policy.js"; import { applyToolPolicyPipeline, buildDefaultToolPolicyPipelineSteps, @@ -124,16 +124,6 @@ function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) { }; } -function resolveFsConfig(params: { cfg?: OpenClawConfig; agentId?: string }) { - const cfg = params.cfg; - const globalFs = cfg?.tools?.fs; - const agentFs = - cfg && params.agentId ? resolveAgentConfig(cfg, params.agentId)?.tools?.fs : undefined; - return { - workspaceOnly: agentFs?.workspaceOnly ?? globalFs?.workspaceOnly, - }; -} - export function resolveToolLoopDetectionConfig(params: { cfg?: OpenClawConfig; agentId?: string; @@ -291,7 +281,7 @@ export function createOpenClawCodingTools(options?: { subagentPolicy, ]); const execConfig = resolveExecConfig({ cfg: options?.config, agentId }); - const fsConfig = resolveFsConfig({ cfg: options?.config, agentId }); + const fsConfig = resolveToolFsConfig({ cfg: options?.config, agentId }); const fsPolicy = createToolFsPolicy({ workspaceOnly: fsConfig.workspaceOnly, }); diff --git a/src/agents/tool-fs-policy.test.ts b/src/agents/tool-fs-policy.test.ts new file mode 100644 index 000000000000..e0fd6a953015 --- /dev/null +++ b/src/agents/tool-fs-policy.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveEffectiveToolFsWorkspaceOnly } from "./tool-fs-policy.js"; + +describe("resolveEffectiveToolFsWorkspaceOnly", () => { + it("returns false by default when tools.fs.workspaceOnly is unset", () => { + expect(resolveEffectiveToolFsWorkspaceOnly({ cfg: {}, agentId: "main" })).toBe(false); + }); + + it("uses global tools.fs.workspaceOnly when no agent override exists", () => { + const cfg: OpenClawConfig = { + tools: { fs: { workspaceOnly: true } }, + }; + expect(resolveEffectiveToolFsWorkspaceOnly({ cfg, agentId: "main" })).toBe(true); + }); + + it("prefers agent-specific tools.fs.workspaceOnly override over global setting", () => { + const cfg: OpenClawConfig = { + tools: { fs: { workspaceOnly: true } }, + agents: { + list: [ + { + id: "main", + tools: { + fs: { workspaceOnly: false }, + }, + }, + ], + }, + }; + expect(resolveEffectiveToolFsWorkspaceOnly({ cfg, agentId: "main" })).toBe(false); + }); + + it("supports agent-specific enablement when global workspaceOnly is off", () => { + const cfg: OpenClawConfig = { + tools: { fs: { workspaceOnly: false } }, + agents: { + list: [ + { + id: "main", + tools: { + fs: { workspaceOnly: true }, + }, + }, + ], + }, + }; + expect(resolveEffectiveToolFsWorkspaceOnly({ cfg, agentId: "main" })).toBe(true); + }); +}); diff --git a/src/agents/tool-fs-policy.ts b/src/agents/tool-fs-policy.ts index 20ce5a447a62..59d04c56e676 100644 --- a/src/agents/tool-fs-policy.ts +++ b/src/agents/tool-fs-policy.ts @@ -1,3 +1,6 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { resolveAgentConfig } from "./agent-scope.js"; + export type ToolFsPolicy = { workspaceOnly: boolean; }; @@ -7,3 +10,22 @@ export function createToolFsPolicy(params: { workspaceOnly?: boolean }): ToolFsP workspaceOnly: params.workspaceOnly === true, }; } + +export function resolveToolFsConfig(params: { cfg?: OpenClawConfig; agentId?: string }): { + workspaceOnly?: boolean; +} { + const cfg = params.cfg; + const globalFs = cfg?.tools?.fs; + const agentFs = + cfg && params.agentId ? resolveAgentConfig(cfg, params.agentId)?.tools?.fs : undefined; + return { + workspaceOnly: agentFs?.workspaceOnly ?? globalFs?.workspaceOnly, + }; +} + +export function resolveEffectiveToolFsWorkspaceOnly(params: { + cfg?: OpenClawConfig; + agentId?: string; +}): boolean { + return resolveToolFsConfig(params).workspaceOnly === true; +} From d18ae2256fa7a86705a636d0940c80e80845740c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 15:15:11 +0000 Subject: [PATCH 203/408] refactor: unify channel plugin resolution, family ordering, and changelog entry tooling --- docs/reference/RELEASING.md | 1 + package.json | 1 + scripts/changelog-add.ts | 123 ++++++++++++++ src/config/plugin-auto-enable.ts | 249 +++++++++++++---------------- src/infra/net/ssrf.pinning.test.ts | 10 ++ src/infra/net/ssrf.ts | 33 ++-- test/scripts/changelog-add.test.ts | 45 ++++++ 7 files changed, 308 insertions(+), 154 deletions(-) create mode 100644 scripts/changelog-add.ts create mode 100644 test/scripts/changelog-add.test.ts diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 6b5dc29c9b93..163759e75132 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -38,6 +38,7 @@ When the operator says “release”, immediately do this preflight (no extra qu 3. **Changelog & docs** - [ ] Update `CHANGELOG.md` with user-facing highlights (create the file if missing); keep entries strictly descending by version. + - Tip: use `pnpm changelog:add -- --section fixes --entry "Your entry. (#12345) Thanks @contrib."` (or `--section changes`) to append deterministically under the current Unreleased block. - [ ] Ensure README examples/flags match current CLI behavior (notably new commands or options). 4. **Validation** diff --git a/package.json b/package.json index 66a60a5dc00b..76d0868422f2 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", + "changelog:add": "node --import tsx scripts/changelog-add.ts", "check": "pnpm format:check && pnpm tsgo && pnpm lint", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", diff --git a/scripts/changelog-add.ts b/scripts/changelog-add.ts new file mode 100644 index 000000000000..2422a00ef4b1 --- /dev/null +++ b/scripts/changelog-add.ts @@ -0,0 +1,123 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +export type UnreleasedSection = "changes" | "fixes"; + +function normalizeEntry(entry: string): string { + const trimmed = entry.trim(); + if (!trimmed) { + throw new Error("entry must not be empty"); + } + return trimmed.startsWith("- ") ? trimmed : `- ${trimmed}`; +} + +function sectionHeading(section: UnreleasedSection): string { + return section === "changes" ? "### Changes" : "### Fixes"; +} + +export function insertUnreleasedChangelogEntry( + changelogContent: string, + section: UnreleasedSection, + entry: string, +): string { + const normalizedEntry = normalizeEntry(entry); + const lines = changelogContent.split(/\r?\n/); + const unreleasedHeaderIndex = lines.findIndex((line) => + /^##\s+.+\s+\(Unreleased\)\s*$/.test(line.trim()), + ); + if (unreleasedHeaderIndex < 0) { + throw new Error("could not find an '(Unreleased)' changelog section"); + } + + const unreleasedEndIndex = lines.findIndex( + (line, index) => index > unreleasedHeaderIndex && /^##\s+/.test(line.trim()), + ); + const unreleasedLimit = unreleasedEndIndex < 0 ? lines.length : unreleasedEndIndex; + const sectionLabel = sectionHeading(section); + const sectionStartIndex = lines.findIndex( + (line, index) => + index > unreleasedHeaderIndex && index < unreleasedLimit && line.trim() === sectionLabel, + ); + if (sectionStartIndex < 0) { + throw new Error(`could not find '${sectionLabel}' under unreleased section`); + } + + const sectionEndIndex = lines.findIndex( + (line, index) => + index > sectionStartIndex && + index < unreleasedLimit && + (/^###\s+/.test(line.trim()) || /^##\s+/.test(line.trim())), + ); + const targetIndex = sectionEndIndex < 0 ? unreleasedLimit : sectionEndIndex; + let insertionIndex = targetIndex; + while (insertionIndex > sectionStartIndex + 1 && lines[insertionIndex - 1].trim() === "") { + insertionIndex -= 1; + } + + if ( + lines.slice(sectionStartIndex + 1, targetIndex).some((line) => line.trim() === normalizedEntry) + ) { + return changelogContent; + } + + lines.splice(insertionIndex, 0, normalizedEntry); + return `${lines.join("\n")}\n`; +} + +type CliArgs = { + section: UnreleasedSection; + entry: string; + file: string; +}; + +function parseCliArgs(argv: string[]): CliArgs { + let section: UnreleasedSection | null = null; + let entry = ""; + let file = "CHANGELOG.md"; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--section") { + const value = argv[i + 1]; + if (value !== "changes" && value !== "fixes") { + throw new Error("--section must be one of: changes, fixes"); + } + section = value; + i += 1; + continue; + } + if (arg === "--entry") { + entry = argv[i + 1] ?? ""; + i += 1; + continue; + } + if (arg === "--file") { + file = argv[i + 1] ?? file; + i += 1; + continue; + } + throw new Error(`unknown argument: ${arg}`); + } + + if (!section) { + throw new Error("missing --section "); + } + if (!entry.trim()) { + throw new Error("missing --entry "); + } + return { section, entry, file }; +} + +function runCli(): void { + const args = parseCliArgs(process.argv.slice(2)); + const changelogPath = resolve(process.cwd(), args.file); + const content = readFileSync(changelogPath, "utf8"); + const next = insertUnreleasedChangelogEntry(content, args.section, args.entry); + if (next !== content) { + writeFileSync(changelogPath, next, "utf8"); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runCli(); +} diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 153f0b304d13..554e96843bcb 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -49,7 +49,7 @@ function recordHasKeys(value: unknown): boolean { return isRecord(value) && Object.keys(value).length > 0; } -function accountsHaveKeys(value: unknown, keys: string[]): boolean { +function accountsHaveKeys(value: unknown, keys: readonly string[]): boolean { if (!isRecord(value)) { return false; } @@ -75,108 +75,95 @@ function resolveChannelConfig( return isRecord(entry) ? entry : null; } -function isTelegramConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { - if (hasNonEmptyString(env.TELEGRAM_BOT_TOKEN)) { - return true; - } - const entry = resolveChannelConfig(cfg, "telegram"); - if (!entry) { - return false; - } - if (hasNonEmptyString(entry.botToken) || hasNonEmptyString(entry.tokenFile)) { - return true; - } - if (accountsHaveKeys(entry.accounts, ["botToken", "tokenFile"])) { - return true; +type StructuredChannelConfigSpec = { + envAny?: readonly string[]; + envAll?: readonly string[]; + stringKeys?: readonly string[]; + numberKeys?: readonly string[]; + accountStringKeys?: readonly string[]; +}; + +const STRUCTURED_CHANNEL_CONFIG_SPECS: Record = { + telegram: { + envAny: ["TELEGRAM_BOT_TOKEN"], + stringKeys: ["botToken", "tokenFile"], + accountStringKeys: ["botToken", "tokenFile"], + }, + discord: { + envAny: ["DISCORD_BOT_TOKEN"], + stringKeys: ["token"], + accountStringKeys: ["token"], + }, + irc: { + envAll: ["IRC_HOST", "IRC_NICK"], + stringKeys: ["host", "nick"], + accountStringKeys: ["host", "nick"], + }, + slack: { + envAny: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"], + stringKeys: ["botToken", "appToken", "userToken"], + accountStringKeys: ["botToken", "appToken", "userToken"], + }, + signal: { + stringKeys: ["account", "httpUrl", "httpHost", "cliPath"], + numberKeys: ["httpPort"], + accountStringKeys: ["account", "httpUrl", "httpHost", "cliPath"], + }, + imessage: { + stringKeys: ["cliPath"], + }, +}; + +function envHasAnyKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean { + for (const key of keys) { + if (hasNonEmptyString(env[key])) { + return true; + } } - return recordHasKeys(entry); + return false; } -function isDiscordConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { - if (hasNonEmptyString(env.DISCORD_BOT_TOKEN)) { - return true; - } - const entry = resolveChannelConfig(cfg, "discord"); - if (!entry) { - return false; - } - if (hasNonEmptyString(entry.token)) { - return true; - } - if (accountsHaveKeys(entry.accounts, ["token"])) { - return true; +function envHasAllKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean { + for (const key of keys) { + if (!hasNonEmptyString(env[key])) { + return false; + } } - return recordHasKeys(entry); + return keys.length > 0; } -function isIrcConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { - if (hasNonEmptyString(env.IRC_HOST) && hasNonEmptyString(env.IRC_NICK)) { - return true; - } - const entry = resolveChannelConfig(cfg, "irc"); - if (!entry) { - return false; - } - if (hasNonEmptyString(entry.host) || hasNonEmptyString(entry.nick)) { - return true; - } - if (accountsHaveKeys(entry.accounts, ["host", "nick"])) { - return true; +function hasAnyNumberKeys(entry: Record, keys: readonly string[]): boolean { + for (const key of keys) { + if (typeof entry[key] === "number") { + return true; + } } - return recordHasKeys(entry); + return false; } -function isSlackConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { - if ( - hasNonEmptyString(env.SLACK_BOT_TOKEN) || - hasNonEmptyString(env.SLACK_APP_TOKEN) || - hasNonEmptyString(env.SLACK_USER_TOKEN) - ) { - return true; - } - const entry = resolveChannelConfig(cfg, "slack"); - if (!entry) { - return false; - } - if ( - hasNonEmptyString(entry.botToken) || - hasNonEmptyString(entry.appToken) || - hasNonEmptyString(entry.userToken) - ) { +function isStructuredChannelConfigured( + cfg: OpenClawConfig, + channelId: string, + env: NodeJS.ProcessEnv, + spec: StructuredChannelConfigSpec, +): boolean { + if (spec.envAny && envHasAnyKeys(env, spec.envAny)) { return true; } - if (accountsHaveKeys(entry.accounts, ["botToken", "appToken", "userToken"])) { + if (spec.envAll && envHasAllKeys(env, spec.envAll)) { return true; } - return recordHasKeys(entry); -} - -function isSignalConfigured(cfg: OpenClawConfig): boolean { - const entry = resolveChannelConfig(cfg, "signal"); + const entry = resolveChannelConfig(cfg, channelId); if (!entry) { return false; } - if ( - hasNonEmptyString(entry.account) || - hasNonEmptyString(entry.httpUrl) || - hasNonEmptyString(entry.httpHost) || - typeof entry.httpPort === "number" || - hasNonEmptyString(entry.cliPath) - ) { + if (spec.stringKeys && spec.stringKeys.some((key) => hasNonEmptyString(entry[key]))) { return true; } - if (accountsHaveKeys(entry.accounts, ["account", "httpUrl", "httpHost", "cliPath"])) { + if (spec.numberKeys && hasAnyNumberKeys(entry, spec.numberKeys)) { return true; } - return recordHasKeys(entry); -} - -function isIMessageConfigured(cfg: OpenClawConfig): boolean { - const entry = resolveChannelConfig(cfg, "imessage"); - if (!entry) { - return false; - } - if (hasNonEmptyString(entry.cliPath)) { + if (spec.accountStringKeys && accountsHaveKeys(entry.accounts, spec.accountStringKeys)) { return true; } return recordHasKeys(entry); @@ -203,24 +190,14 @@ export function isChannelConfigured( channelId: string, env: NodeJS.ProcessEnv = process.env, ): boolean { - switch (channelId) { - case "whatsapp": - return isWhatsAppConfigured(cfg); - case "telegram": - return isTelegramConfigured(cfg, env); - case "discord": - return isDiscordConfigured(cfg, env); - case "irc": - return isIrcConfigured(cfg, env); - case "slack": - return isSlackConfigured(cfg, env); - case "signal": - return isSignalConfigured(cfg); - case "imessage": - return isIMessageConfigured(cfg); - default: - return isGenericChannelConfigured(cfg, channelId); + if (channelId === "whatsapp") { + return isWhatsAppConfigured(cfg); + } + const spec = STRUCTURED_CHANNEL_CONFIG_SPECS[channelId]; + if (spec) { + return isStructuredChannelConfigured(cfg, channelId, env, spec); } + return isGenericChannelConfigured(cfg, channelId); } function collectModelRefs(cfg: OpenClawConfig): string[] { @@ -325,10 +302,34 @@ function buildChannelToPluginIdMap(registry: PluginManifestRegistry): Map, +): string { + // Third-party plugins can expose a channel id that differs from their + // manifest id; plugins.entries must always be keyed by manifest id. + const builtInId = normalizeChatChannelId(channelId); + if (builtInId) { + return builtInId; + } + return channelToPluginId.get(channelId) ?? channelId; +} + +function collectCandidateChannelIds(cfg: OpenClawConfig): string[] { + const channelIds = new Set(CHANNEL_PLUGIN_IDS); + const configuredChannels = cfg.channels as Record | undefined; + if (!configuredChannels || typeof configuredChannels !== "object") { + return Array.from(channelIds); + } + for (const key of Object.keys(configuredChannels)) { + if (key === "defaults" || key === "modelByChannel") { + continue; + } + const normalizedBuiltIn = normalizeChatChannelId(key); + channelIds.add(normalizedBuiltIn ?? key); + } + return Array.from(channelIds); +} function resolveConfiguredPlugins( cfg: OpenClawConfig, @@ -337,45 +338,9 @@ function resolveConfiguredPlugins( ): PluginEnableChange[] { const changes: PluginEnableChange[] = []; // Build reverse map: channel ID → plugin ID from installed plugin manifests. - // This is needed when a third-party plugin declares a channel ID that differs - // from the plugin's own ID (e.g. plugin id="apn-channel", channels=["apn"]). const channelToPluginId = buildChannelToPluginIdMap(registry); - - // For built-in and catalog entries: channelId === pluginId (they are the same). - const pairs: ChannelPluginPair[] = CHANNEL_PLUGIN_IDS.map((id) => ({ - channelId: id, - pluginId: id, - })); - - const configuredChannels = cfg.channels as Record | undefined; - if (configuredChannels && typeof configuredChannels === "object") { - for (const key of Object.keys(configuredChannels)) { - if (key === "defaults" || key === "modelByChannel") { - continue; - } - const builtInId = normalizeChatChannelId(key); - if (builtInId) { - // Built-in channel: channelId and pluginId are the same. - pairs.push({ channelId: builtInId, pluginId: builtInId }); - } else { - // Third-party channel plugin: look up the actual plugin ID from the - // manifest registry. If the plugin declares channels=["apn"] but its - // id is "apn-channel", we must use "apn-channel" as the pluginId so - // that plugins.entries is keyed correctly. Fall back to the channel key - // when no installed manifest declares this channel. - const pluginId = channelToPluginId.get(key) ?? key; - pairs.push({ channelId: key, pluginId }); - } - } - } - - // Deduplicate by channelId, preserving first occurrence. - const seenChannelIds = new Set(); - for (const { channelId, pluginId } of pairs) { - if (!channelId || !pluginId || seenChannelIds.has(channelId)) { - continue; - } - seenChannelIds.add(channelId); + for (const channelId of collectCandidateChannelIds(cfg)) { + const pluginId = resolvePluginIdForChannel(channelId, channelToPluginId); if (isChannelConfigured(cfg, channelId, env)) { changes.push({ pluginId, reason: `${channelId} configured` }); } diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 73f91d9d5368..28420ea373f1 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -172,6 +172,16 @@ describe("ssrf pinning", () => { ]); }); + it("uses DNS family metadata for ordering (not address string heuristics)", async () => { + const lookup = vi.fn(async () => [ + { address: "2606:2800:220:1:248:1893:25c8:1946", family: 4 }, + { address: "93.184.216.34", family: 6 }, + ]) as unknown as LookupFn; + + const pinned = await resolvePinnedHostname("example.com", lookup); + expect(pinned.addresses).toEqual(["2606:2800:220:1:248:1893:25c8:1946", "93.184.216.34"]); + }); + it("allows ISATAP embedded private IPv4 when private network is explicitly enabled", async () => { const lookup = vi.fn(async () => [ { address: "2001:db8:1234::5efe:127.0.0.1", family: 6 }, diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 0d77bfeb35d8..b84469390c08 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -255,6 +255,24 @@ export type PinnedHostname = { lookup: typeof dnsLookupCb; }; +function dedupeAndPreferIpv4(results: readonly LookupAddress[]): string[] { + const seen = new Set(); + const ipv4: string[] = []; + const otherFamilies: string[] = []; + for (const entry of results) { + if (seen.has(entry.address)) { + continue; + } + seen.add(entry.address); + if (entry.family === 4) { + ipv4.push(entry.address); + continue; + } + otherFamilies.push(entry.address); + } + return [...ipv4, ...otherFamilies]; +} + export async function resolvePinnedHostnameWithPolicy( hostname: string, params: { lookupFn?: LookupFn; policy?: SsrFPolicy } = {}, @@ -290,18 +308,9 @@ export async function resolvePinnedHostnameWithPolicy( assertAllowedResolvedAddressesOrThrow(results, params.policy); } - // Sort IPv4 addresses before IPv6 so that Happy Eyeballs (autoSelectFamily) and - // round-robin pinned lookups try IPv4 first. This avoids connection failures on - // hosts where IPv6 is configured but not routed (common on cloud VMs and WSL2). - // See: https://github.com/openclaw/openclaw/issues/23975 - const addresses = Array.from(new Set(results.map((entry) => entry.address))).toSorted((a, b) => { - const aIsV6 = a.includes(":"); - const bIsV6 = b.includes(":"); - if (aIsV6 === bIsV6) { - return 0; - } - return aIsV6 ? 1 : -1; - }); + // Prefer addresses returned as IPv4 by DNS family metadata before other + // families so Happy Eyeballs and pinned round-robin both attempt IPv4 first. + const addresses = dedupeAndPreferIpv4(results); if (addresses.length === 0) { throw new Error(`Unable to resolve hostname: ${hostname}`); } diff --git a/test/scripts/changelog-add.test.ts b/test/scripts/changelog-add.test.ts new file mode 100644 index 000000000000..f9c0d4755d88 --- /dev/null +++ b/test/scripts/changelog-add.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { insertUnreleasedChangelogEntry } from "../../scripts/changelog-add.ts"; + +const SAMPLE = `# Changelog + +## 2026.2.24 (Unreleased) + +### Changes + +- Existing change. + +### Fixes + +- Existing fix. + +## 2026.2.23 + +### Changes + +- Older entry. +`; + +describe("changelog-add", () => { + it("inserts a new unreleased fixes entry before the next version section", () => { + const next = insertUnreleasedChangelogEntry( + SAMPLE, + "fixes", + "New fix entry. (#123) Thanks @someone.", + ); + expect(next).toContain( + "- Existing fix.\n- New fix entry. (#123) Thanks @someone.\n\n## 2026.2.23", + ); + }); + + it("normalizes missing bullet prefix", () => { + const next = insertUnreleasedChangelogEntry(SAMPLE, "changes", "New change."); + expect(next).toContain("- Existing change.\n- New change.\n\n### Fixes"); + }); + + it("does not duplicate identical entry", () => { + const once = insertUnreleasedChangelogEntry(SAMPLE, "fixes", "New fix."); + const twice = insertUnreleasedChangelogEntry(once, "fixes", "New fix."); + expect(twice).toBe(once); + }); +}); From d06d8701fd680faf1e81ac025d58df8c5443586f Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Sun, 22 Feb 2026 14:42:23 +0000 Subject: [PATCH 204/408] iOS: normalize watch quick actions and fix test signing --- apps/ios/Sources/Model/NodeAppModel.swift | 108 +++++++++++++++++- apps/ios/Sources/OpenClawApp.swift | 91 ++++++++++----- .../GatewayConnectionSecurityTests.swift | 1 + apps/ios/Tests/NodeAppModelInvokeTests.swift | 73 ++++++++++++ apps/ios/project.yml | 6 + 5 files changed, 246 insertions(+), 33 deletions(-) diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index fc5e6097b18a..41a1e19fd446 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1490,8 +1490,9 @@ private extension NodeAppModel { return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) case OpenClawWatchCommand.notify.rawValue: let params = try Self.decodeParams(OpenClawWatchNotifyParams.self, from: req.paramsJSON) - let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) - let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedParams = Self.normalizeWatchNotifyParams(params) + let title = normalizedParams.title + let body = normalizedParams.body if title.isEmpty && body.isEmpty { return BridgeInvokeResponse( id: req.id, @@ -1503,13 +1504,13 @@ private extension NodeAppModel { do { let result = try await self.watchMessagingService.sendNotification( id: req.id, - params: params) + params: normalizedParams) if result.queuedForDelivery || !result.deliveredImmediately { let invokeID = req.id Task { @MainActor in await WatchPromptNotificationBridge.scheduleMirroredWatchPromptNotificationIfNeeded( invokeID: invokeID, - params: params, + params: normalizedParams, sendResult: result) } } @@ -1535,6 +1536,105 @@ private extension NodeAppModel { } } + private static func normalizeWatchNotifyParams(_ params: OpenClawWatchNotifyParams) -> OpenClawWatchNotifyParams { + var normalized = params + normalized.title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + normalized.body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + normalized.promptId = self.trimmedOrNil(params.promptId) + normalized.sessionKey = self.trimmedOrNil(params.sessionKey) + normalized.kind = self.trimmedOrNil(params.kind) + normalized.details = self.trimmedOrNil(params.details) + normalized.priority = self.normalizedWatchPriority(params.priority, risk: params.risk) + normalized.risk = self.normalizedWatchRisk(params.risk, priority: normalized.priority) + + let normalizedActions = self.normalizeWatchActions( + params.actions, + kind: normalized.kind, + promptId: normalized.promptId) + normalized.actions = normalizedActions.isEmpty ? nil : normalizedActions + return normalized + } + + private static func normalizeWatchActions( + _ actions: [OpenClawWatchAction]?, + kind: String?, + promptId: String?) -> [OpenClawWatchAction] + { + let provided = (actions ?? []).compactMap { action -> OpenClawWatchAction? in + let id = action.id.trimmingCharacters(in: .whitespacesAndNewlines) + let label = action.label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty, !label.isEmpty else { return nil } + return OpenClawWatchAction( + id: id, + label: label, + style: self.trimmedOrNil(action.style)) + } + if !provided.isEmpty { + return Array(provided.prefix(4)) + } + + // Only auto-insert quick actions when this is a prompt/decision flow. + guard promptId?.isEmpty == false else { + return [] + } + + let normalizedKind = kind?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + if normalizedKind.contains("approval") || normalizedKind.contains("approve") { + return [ + OpenClawWatchAction(id: "approve", label: "Approve"), + OpenClawWatchAction(id: "decline", label: "Decline", style: "destructive"), + OpenClawWatchAction(id: "open_phone", label: "Open iPhone"), + OpenClawWatchAction(id: "escalate", label: "Escalate"), + ] + } + + return [ + OpenClawWatchAction(id: "done", label: "Done"), + OpenClawWatchAction(id: "snooze_10m", label: "Snooze 10m"), + OpenClawWatchAction(id: "open_phone", label: "Open iPhone"), + OpenClawWatchAction(id: "escalate", label: "Escalate"), + ] + } + + private static func normalizedWatchRisk( + _ risk: OpenClawWatchRisk?, + priority: OpenClawNotificationPriority?) -> OpenClawWatchRisk? + { + if let risk { return risk } + switch priority { + case .passive: + return .low + case .active: + return .medium + case .timeSensitive: + return .high + case nil: + return nil + } + } + + private static func normalizedWatchPriority( + _ priority: OpenClawNotificationPriority?, + risk: OpenClawWatchRisk?) -> OpenClawNotificationPriority? + { + if let priority { return priority } + switch risk { + case .low: + return .passive + case .medium: + return .active + case .high: + return .timeSensitive + case nil: + return nil + } + } + + private static func trimmedOrNil(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + func locationMode() -> OpenClawLocationMode { let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" return OpenClawLocationMode(rawValue: raw) ?? .off diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 335e09fd986c..0dc0c4cac26f 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -182,8 +182,30 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc actionLabel: actionLabel, sessionKey: sessionKey) default: + break + } + + guard response.actionIdentifier.hasPrefix(WatchPromptNotificationBridge.actionIdentifierPrefix) else { + return nil + } + let indexString = String( + response.actionIdentifier.dropFirst(WatchPromptNotificationBridge.actionIdentifierPrefix.count)) + guard let actionIndex = Int(indexString), actionIndex >= 0 else { return nil } + let actionIdKey = WatchPromptNotificationBridge.actionIDKey(index: actionIndex) + let actionLabelKey = WatchPromptNotificationBridge.actionLabelKey(index: actionIndex) + let actionId = (userInfo[actionIdKey] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !actionId.isEmpty else { + return nil + } + let actionLabel = userInfo[actionLabelKey] as? String + return PendingWatchPromptAction( + promptId: promptId, + actionId: actionId, + actionLabel: actionLabel, + sessionKey: sessionKey) } private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async { @@ -243,6 +265,9 @@ enum WatchPromptNotificationBridge { static let actionSecondaryLabelKey = "openclaw.watch.action.secondary.label" static let actionPrimaryIdentifier = "openclaw.watch.action.primary" static let actionSecondaryIdentifier = "openclaw.watch.action.secondary" + static let actionIdentifierPrefix = "openclaw.watch.action." + static let actionIDKeyPrefix = "openclaw.watch.action.id." + static let actionLabelKeyPrefix = "openclaw.watch.action.label." static let categoryPrefix = "openclaw.watch.prompt.category." @MainActor @@ -264,16 +289,15 @@ enum WatchPromptNotificationBridge { guard !id.isEmpty, !label.isEmpty else { return nil } return OpenClawWatchAction(id: id, label: label, style: action.style) } - let primaryAction = normalizedActions.first - let secondaryAction = normalizedActions.dropFirst().first + let displayedActions = Array(normalizedActions.prefix(4)) let center = UNUserNotificationCenter.current() var categoryIdentifier = "" - if let primaryAction { + if !displayedActions.isEmpty { let categoryID = "\(self.categoryPrefix)\(invokeID)" let category = UNNotificationCategory( identifier: categoryID, - actions: self.categoryActions(primaryAction: primaryAction, secondaryAction: secondaryAction), + actions: self.categoryActions(displayedActions), intentIdentifiers: [], options: []) await self.upsertNotificationCategory(category, center: center) @@ -289,13 +313,16 @@ enum WatchPromptNotificationBridge { if let sessionKey = params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionKey.isEmpty { userInfo[self.sessionKeyKey] = sessionKey } - if let primaryAction { - userInfo[self.actionPrimaryIDKey] = primaryAction.id - userInfo[self.actionPrimaryLabelKey] = primaryAction.label - } - if let secondaryAction { - userInfo[self.actionSecondaryIDKey] = secondaryAction.id - userInfo[self.actionSecondaryLabelKey] = secondaryAction.label + for (index, action) in displayedActions.enumerated() { + userInfo[self.actionIDKey(index: index)] = action.id + userInfo[self.actionLabelKey(index: index)] = action.label + if index == 0 { + userInfo[self.actionPrimaryIDKey] = action.id + userInfo[self.actionPrimaryLabelKey] = action.label + } else if index == 1 { + userInfo[self.actionSecondaryIDKey] = action.id + userInfo[self.actionSecondaryLabelKey] = action.label + } } let content = UNMutableNotificationContent() @@ -324,24 +351,30 @@ enum WatchPromptNotificationBridge { try? await self.addNotificationRequest(request, center: center) } - private static func categoryActions( - primaryAction: OpenClawWatchAction, - secondaryAction: OpenClawWatchAction?) -> [UNNotificationAction] - { - var actions: [UNNotificationAction] = [ - UNNotificationAction( - identifier: self.actionPrimaryIdentifier, - title: primaryAction.label, - options: self.notificationActionOptions(style: primaryAction.style)) - ] - if let secondaryAction { - actions.append( - UNNotificationAction( - identifier: self.actionSecondaryIdentifier, - title: secondaryAction.label, - options: self.notificationActionOptions(style: secondaryAction.style))) - } - return actions + static func actionIDKey(index: Int) -> String { + "\(self.actionIDKeyPrefix)\(index)" + } + + static func actionLabelKey(index: Int) -> String { + "\(self.actionLabelKeyPrefix)\(index)" + } + + private static func categoryActions(_ actions: [OpenClawWatchAction]) -> [UNNotificationAction] { + actions.enumerated().map { index, action in + let identifier: String + switch index { + case 0: + identifier = self.actionPrimaryIdentifier + case 1: + identifier = self.actionSecondaryIdentifier + default: + identifier = "\(self.actionIdentifierPrefix)\(index)" + } + return UNNotificationAction( + identifier: identifier, + title: action.label, + options: self.notificationActionOptions(style: action.style)) + } } private static func notificationActionOptions(style: String?) -> UNNotificationActionOptions { diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift index b82ae7161687..3c1b25bce077 100644 --- a/apps/ios/Tests/GatewayConnectionSecurityTests.swift +++ b/apps/ios/Tests/GatewayConnectionSecurityTests.swift @@ -1,5 +1,6 @@ import Foundation import Network +import OpenClawKit import Testing @testable import OpenClaw diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 24bc4ba06391..dbeee118a4a4 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -302,6 +302,79 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer #expect(watchService.lastSent == nil) } + @Test @MainActor func handleInvokeWatchNotifyAddsDefaultActionsForPrompt() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams( + title: "Task", + body: "Action needed", + priority: .passive, + promptId: "prompt-123") + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-default-actions", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + #expect(watchService.lastSent?.params.risk == .low) + let actionIDs = watchService.lastSent?.params.actions?.map(\.id) + #expect(actionIDs == ["done", "snooze_10m", "open_phone", "escalate"]) + } + + @Test @MainActor func handleInvokeWatchNotifyAddsApprovalDefaults() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams( + title: "Approval", + body: "Allow command?", + promptId: "prompt-approval", + kind: "approval") + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-approval-defaults", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + let actionIDs = watchService.lastSent?.params.actions?.map(\.id) + #expect(actionIDs == ["approve", "decline", "open_phone", "escalate"]) + #expect(watchService.lastSent?.params.actions?[1].style == "destructive") + } + + @Test @MainActor func handleInvokeWatchNotifyDerivesPriorityFromRiskAndCapsActions() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams( + title: "Urgent", + body: "Check now", + risk: .high, + actions: [ + OpenClawWatchAction(id: "a1", label: "A1"), + OpenClawWatchAction(id: "a2", label: "A2"), + OpenClawWatchAction(id: "a3", label: "A3"), + OpenClawWatchAction(id: "a4", label: "A4"), + OpenClawWatchAction(id: "a5", label: "A5"), + ]) + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-derive-priority", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + #expect(watchService.lastSent?.params.priority == .timeSensitive) + #expect(watchService.lastSent?.params.risk == .high) + let actionIDs = watchService.lastSent?.params.actions?.map(\.id) + #expect(actionIDs == ["a1", "a2", "a3", "a4"]) + } + @Test @MainActor func handleInvokeWatchNotifyReturnsUnavailableOnDeliveryFailure() async throws { let watchService = MockWatchMessagingService() watchService.sendError = NSError( diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 1028876e5101..a4d5928d8206 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -210,6 +210,9 @@ targets: OpenClawTests: type: bundle.unit-test platform: iOS + configFiles: + Debug: Signing.xcconfig + Release: Signing.xcconfig sources: - path: Tests dependencies: @@ -219,6 +222,9 @@ targets: - sdk: AppIntents.framework settings: base: + CODE_SIGN_IDENTITY: "Apple Development" + CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)" + DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)" PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.tests SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete From 0121fa6f1a4ad9a27fbeb3ec5d3b7dbc9cf5248e Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Sun, 22 Feb 2026 15:00:36 +0000 Subject: [PATCH 205/408] Changelog: add PR 23636 iOS/watch notes --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7319d8f73895..a1eb085c4b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -236,6 +236,7 @@ Docs: https://docs.openclaw.ai - Memory/Embeddings: enforce a per-input 8k safety cap before embedding batching and apply a conservative 2k fallback limit for local providers without declared input limits, preventing oversized session/memory chunks from triggering provider context-size failures during sync/indexing. (#6016) Thanks @batumilove. - Memory/QMD: on Windows, resolve bare `qmd`/`mcporter` command names to npm shim executables (`.cmd`) before spawning, so qmd boot updates and mcporter-backed searches no longer fail with `spawn ... ENOENT` on default npm installs. (#23899) Thanks @arcbuilder-ai. - Memory/QMD: parse plain-text `qmd collection list --json` output when older qmd builds ignore JSON mode, and retry memory searches once after re-ensuring managed collections when qmd returns `Collection not found ...`. (#23613) Thanks @leozhucn. +- iOS/Watch: normalize watch quick-action notification payloads, support mirrored indexed actions beyond primary/secondary, and fix iOS test-target signing/compile blockers for watch notify coverage. (#23636) Thanks @mbelinky. - Signal/RPC: guard malformed Signal RPC JSON responses with a clear status-scoped error and add regression coverage for invalid JSON responses. (#22995) Thanks @adhitShet. - Gateway/Subagents: guard gateway and subagent session-key/message trim paths against undefined inputs to prevent early `Cannot read properties of undefined (reading 'trim')` crashes during subagent spawn and wait flows. - Agents/Workspace: guard `resolveUserPath` against undefined/null input to prevent `Cannot read properties of undefined (reading 'trim')` crashes when workspace paths are missing in embedded runner flows. From 4ec0af00fef1e7351dbe982cf9e29ce76f28099f Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Sun, 22 Feb 2026 15:03:34 +0000 Subject: [PATCH 206/408] Agents: fix embedded auth-profile failure helper typing --- src/agents/pi-embedded-runner/run.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index f92b6a375a71..0ed2ba14d65a 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -516,6 +516,8 @@ export async function runEmbeddedPiAgent( const maybeMarkAuthProfileFailure = async (failure: { profileId?: string; reason?: Parameters[0]["reason"] | null; + config?: RunEmbeddedPiAgentParams["config"]; + agentDir?: RunEmbeddedPiAgentParams["agentDir"]; }) => { const { profileId, reason } = failure; if (!profileId || !reason || reason === "timeout") { From 80daaeba3840168c466a1efa9bd74fcf39c6df0e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:36:06 +0000 Subject: [PATCH 207/408] fix(ios): split watch notify normalization helpers Co-authored-by: Mariano Belinky --- ...odeAppModel+WatchNotifyNormalization.swift | 103 ++++++++++++++++++ apps/ios/Sources/Model/NodeAppModel.swift | 99 ----------------- 2 files changed, 103 insertions(+), 99 deletions(-) create mode 100644 apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift diff --git a/apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift b/apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift new file mode 100644 index 000000000000..08ef81e0cced --- /dev/null +++ b/apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift @@ -0,0 +1,103 @@ +import Foundation +import OpenClawKit + +extension NodeAppModel { + static func normalizeWatchNotifyParams(_ params: OpenClawWatchNotifyParams) -> OpenClawWatchNotifyParams { + var normalized = params + normalized.title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + normalized.body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + normalized.promptId = self.trimmedOrNil(params.promptId) + normalized.sessionKey = self.trimmedOrNil(params.sessionKey) + normalized.kind = self.trimmedOrNil(params.kind) + normalized.details = self.trimmedOrNil(params.details) + normalized.priority = self.normalizedWatchPriority(params.priority, risk: params.risk) + normalized.risk = self.normalizedWatchRisk(params.risk, priority: normalized.priority) + + let normalizedActions = self.normalizeWatchActions( + params.actions, + kind: normalized.kind, + promptId: normalized.promptId) + normalized.actions = normalizedActions.isEmpty ? nil : normalizedActions + return normalized + } + + static func normalizeWatchActions( + _ actions: [OpenClawWatchAction]?, + kind: String?, + promptId: String?) -> [OpenClawWatchAction] + { + let provided = (actions ?? []).compactMap { action -> OpenClawWatchAction? in + let id = action.id.trimmingCharacters(in: .whitespacesAndNewlines) + let label = action.label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty, !label.isEmpty else { return nil } + return OpenClawWatchAction( + id: id, + label: label, + style: self.trimmedOrNil(action.style)) + } + if !provided.isEmpty { + return Array(provided.prefix(4)) + } + + // Only auto-insert quick actions when this is a prompt/decision flow. + guard promptId?.isEmpty == false else { + return [] + } + + let normalizedKind = kind?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + if normalizedKind.contains("approval") || normalizedKind.contains("approve") { + return [ + OpenClawWatchAction(id: "approve", label: "Approve"), + OpenClawWatchAction(id: "decline", label: "Decline", style: "destructive"), + OpenClawWatchAction(id: "open_phone", label: "Open iPhone"), + OpenClawWatchAction(id: "escalate", label: "Escalate"), + ] + } + + return [ + OpenClawWatchAction(id: "done", label: "Done"), + OpenClawWatchAction(id: "snooze_10m", label: "Snooze 10m"), + OpenClawWatchAction(id: "open_phone", label: "Open iPhone"), + OpenClawWatchAction(id: "escalate", label: "Escalate"), + ] + } + + static func normalizedWatchRisk( + _ risk: OpenClawWatchRisk?, + priority: OpenClawNotificationPriority?) -> OpenClawWatchRisk? + { + if let risk { return risk } + switch priority { + case .passive: + return .low + case .active: + return .medium + case .timeSensitive: + return .high + case nil: + return nil + } + } + + static func normalizedWatchPriority( + _ priority: OpenClawNotificationPriority?, + risk: OpenClawWatchRisk?) -> OpenClawNotificationPriority? + { + if let priority { return priority } + switch risk { + case .low: + return .passive + case .medium: + return .active + case .high: + return .timeSensitive + case nil: + return nil + } + } + + static func trimmedOrNil(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 41a1e19fd446..d763a3b908f9 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1536,105 +1536,6 @@ private extension NodeAppModel { } } - private static func normalizeWatchNotifyParams(_ params: OpenClawWatchNotifyParams) -> OpenClawWatchNotifyParams { - var normalized = params - normalized.title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) - normalized.body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) - normalized.promptId = self.trimmedOrNil(params.promptId) - normalized.sessionKey = self.trimmedOrNil(params.sessionKey) - normalized.kind = self.trimmedOrNil(params.kind) - normalized.details = self.trimmedOrNil(params.details) - normalized.priority = self.normalizedWatchPriority(params.priority, risk: params.risk) - normalized.risk = self.normalizedWatchRisk(params.risk, priority: normalized.priority) - - let normalizedActions = self.normalizeWatchActions( - params.actions, - kind: normalized.kind, - promptId: normalized.promptId) - normalized.actions = normalizedActions.isEmpty ? nil : normalizedActions - return normalized - } - - private static func normalizeWatchActions( - _ actions: [OpenClawWatchAction]?, - kind: String?, - promptId: String?) -> [OpenClawWatchAction] - { - let provided = (actions ?? []).compactMap { action -> OpenClawWatchAction? in - let id = action.id.trimmingCharacters(in: .whitespacesAndNewlines) - let label = action.label.trimmingCharacters(in: .whitespacesAndNewlines) - guard !id.isEmpty, !label.isEmpty else { return nil } - return OpenClawWatchAction( - id: id, - label: label, - style: self.trimmedOrNil(action.style)) - } - if !provided.isEmpty { - return Array(provided.prefix(4)) - } - - // Only auto-insert quick actions when this is a prompt/decision flow. - guard promptId?.isEmpty == false else { - return [] - } - - let normalizedKind = kind?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" - if normalizedKind.contains("approval") || normalizedKind.contains("approve") { - return [ - OpenClawWatchAction(id: "approve", label: "Approve"), - OpenClawWatchAction(id: "decline", label: "Decline", style: "destructive"), - OpenClawWatchAction(id: "open_phone", label: "Open iPhone"), - OpenClawWatchAction(id: "escalate", label: "Escalate"), - ] - } - - return [ - OpenClawWatchAction(id: "done", label: "Done"), - OpenClawWatchAction(id: "snooze_10m", label: "Snooze 10m"), - OpenClawWatchAction(id: "open_phone", label: "Open iPhone"), - OpenClawWatchAction(id: "escalate", label: "Escalate"), - ] - } - - private static func normalizedWatchRisk( - _ risk: OpenClawWatchRisk?, - priority: OpenClawNotificationPriority?) -> OpenClawWatchRisk? - { - if let risk { return risk } - switch priority { - case .passive: - return .low - case .active: - return .medium - case .timeSensitive: - return .high - case nil: - return nil - } - } - - private static func normalizedWatchPriority( - _ priority: OpenClawNotificationPriority?, - risk: OpenClawWatchRisk?) -> OpenClawNotificationPriority? - { - if let priority { return priority } - switch risk { - case .low: - return .passive - case .medium: - return .active - case .high: - return .timeSensitive - case nil: - return nil - } - } - - private static func trimmedOrNil(_ value: String?) -> String? { - let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed - } - func locationMode() -> OpenClawLocationMode { let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" return OpenClawLocationMode(rawValue: raw) ?? .off From 0f0a680d3df81739ea5088a2f88e65f938b7936b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 15:16:55 +0000 Subject: [PATCH 208/408] fix(exec): block shell-wrapper positional argv approval smuggling --- CHANGELOG.md | 1 + src/infra/system-run-command.test.ts | 19 +++++++ src/infra/system-run-command.ts | 78 +++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1eb085c4b5e..25dfd5361670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting. - Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. - Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. - Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. diff --git a/src/infra/system-run-command.test.ts b/src/infra/system-run-command.test.ts index 4b99c5e1365c..7186823d84b3 100644 --- a/src/infra/system-run-command.test.ts +++ b/src/infra/system-run-command.test.ts @@ -103,6 +103,13 @@ describe("system run command helpers", () => { expect(res.ok).toBe(true); }); + test("validateSystemRunCommandConsistency rejects shell-only rawCommand for positional-argv carrier wrappers", () => { + expectRawCommandMismatch({ + argv: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"], + rawCommand: '$0 "$1"', + }); + }); + test("validateSystemRunCommandConsistency accepts rawCommand matching env shell wrapper argv", () => { const res = validateSystemRunCommandConsistency({ argv: ["/usr/bin/env", "bash", "-lc", "echo hi"], @@ -170,6 +177,18 @@ describe("system run command helpers", () => { expect(res.cmdText).toBe("echo SAFE&&whoami"); }); + test("resolveSystemRunCommand binds cmdText to full argv for shell-wrapper positional-argv carriers", () => { + const res = resolveSystemRunCommand({ + command: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"], + }); + expect(res.ok).toBe(true); + if (!res.ok) { + throw new Error("unreachable"); + } + expect(res.shellCommand).toBe('$0 "$1"'); + expect(res.cmdText).toBe('/bin/sh -lc "$0 \\"$1\\"" /usr/bin/touch /tmp/marker'); + }); + test("resolveSystemRunCommand binds cmdText to full argv when env prelude modifies shell wrapper", () => { const res = resolveSystemRunCommand({ command: ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"], diff --git a/src/infra/system-run-command.ts b/src/infra/system-run-command.ts index c8bbac6e7a9e..b03d715fc72d 100644 --- a/src/infra/system-run-command.ts +++ b/src/infra/system-run-command.ts @@ -1,6 +1,9 @@ import { extractShellWrapperCommand, hasEnvManipulationBeforeShellWrapper, + normalizeExecutableToken, + unwrapDispatchWrappersForResolution, + unwrapKnownShellMultiplexerInvocation, } from "./exec-wrapper-resolution.js"; export type SystemRunCommandValidation = @@ -49,6 +52,77 @@ export function extractShellCommandFromArgv(argv: string[]): string | null { return extractShellWrapperCommand(argv).command; } +const POSIX_OR_POWERSHELL_INLINE_WRAPPER_NAMES = new Set([ + "ash", + "bash", + "dash", + "fish", + "ksh", + "powershell", + "pwsh", + "sh", + "zsh", +]); + +const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]); +const POWERSHELL_INLINE_COMMAND_FLAGS = new Set(["-c", "-command", "--command"]); + +function unwrapShellWrapperArgv(argv: string[]): string[] { + const dispatchUnwrapped = unwrapDispatchWrappersForResolution(argv); + const shellMultiplexer = unwrapKnownShellMultiplexerInvocation(dispatchUnwrapped); + return shellMultiplexer.kind === "unwrapped" ? shellMultiplexer.argv : dispatchUnwrapped; +} + +function resolveInlineCommandTokenIndex( + argv: string[], + flags: ReadonlySet, + options: { allowCombinedC?: boolean } = {}, +): number | null { + for (let i = 1; i < argv.length; i += 1) { + const token = argv[i]?.trim(); + if (!token) { + continue; + } + const lower = token.toLowerCase(); + if (lower === "--") { + break; + } + if (flags.has(lower)) { + return i + 1 < argv.length ? i + 1 : null; + } + if (options.allowCombinedC && /^-[^-]*c[^-]*$/i.test(token)) { + const commandIndex = lower.indexOf("c"); + const inline = token.slice(commandIndex + 1).trim(); + return inline ? i : i + 1 < argv.length ? i + 1 : null; + } + } + return null; +} + +function hasTrailingPositionalArgvAfterInlineCommand(argv: string[]): boolean { + const wrapperArgv = unwrapShellWrapperArgv(argv); + const token0 = wrapperArgv[0]?.trim(); + if (!token0) { + return false; + } + + const wrapper = normalizeExecutableToken(token0); + if (!POSIX_OR_POWERSHELL_INLINE_WRAPPER_NAMES.has(wrapper)) { + return false; + } + + const inlineCommandIndex = + wrapper === "powershell" || wrapper === "pwsh" + ? resolveInlineCommandTokenIndex(wrapperArgv, POWERSHELL_INLINE_COMMAND_FLAGS) + : resolveInlineCommandTokenIndex(wrapperArgv, POSIX_INLINE_COMMAND_FLAGS, { + allowCombinedC: true, + }); + if (inlineCommandIndex === null) { + return false; + } + return wrapperArgv.slice(inlineCommandIndex + 1).some((entry) => entry.trim().length > 0); +} + export function validateSystemRunCommandConsistency(params: { argv: string[]; rawCommand?: string | null; @@ -59,10 +133,12 @@ export function validateSystemRunCommandConsistency(params: { : null; const shellWrapperResolution = extractShellWrapperCommand(params.argv); const shellCommand = shellWrapperResolution.command; + const shellWrapperPositionalArgv = hasTrailingPositionalArgvAfterInlineCommand(params.argv); const envManipulationBeforeShellWrapper = shellWrapperResolution.isWrapper && hasEnvManipulationBeforeShellWrapper(params.argv); + const mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv; const inferred = - shellCommand !== null && !envManipulationBeforeShellWrapper + shellCommand !== null && !mustBindDisplayToFullArgv ? shellCommand.trim() : formatExecCommand(params.argv); From e806b34779215e0cd4b5fe69bcbeb254a080b24c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 15:32:58 +0000 Subject: [PATCH 209/408] chore: remove changelog add helper script --- docs/reference/RELEASING.md | 1 - package.json | 1 - scripts/changelog-add.ts | 123 ----------------------------- test/scripts/changelog-add.test.ts | 45 ----------- 4 files changed, 170 deletions(-) delete mode 100644 scripts/changelog-add.ts delete mode 100644 test/scripts/changelog-add.test.ts diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 163759e75132..6b5dc29c9b93 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -38,7 +38,6 @@ When the operator says “release”, immediately do this preflight (no extra qu 3. **Changelog & docs** - [ ] Update `CHANGELOG.md` with user-facing highlights (create the file if missing); keep entries strictly descending by version. - - Tip: use `pnpm changelog:add -- --section fixes --entry "Your entry. (#12345) Thanks @contrib."` (or `--section changes`) to append deterministically under the current Unreleased block. - [ ] Ensure README examples/flags match current CLI behavior (notably new commands or options). 4. **Validation** diff --git a/package.json b/package.json index 76d0868422f2..66a60a5dc00b 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,6 @@ "build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "changelog:add": "node --import tsx scripts/changelog-add.ts", "check": "pnpm format:check && pnpm tsgo && pnpm lint", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", diff --git a/scripts/changelog-add.ts b/scripts/changelog-add.ts deleted file mode 100644 index 2422a00ef4b1..000000000000 --- a/scripts/changelog-add.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; - -export type UnreleasedSection = "changes" | "fixes"; - -function normalizeEntry(entry: string): string { - const trimmed = entry.trim(); - if (!trimmed) { - throw new Error("entry must not be empty"); - } - return trimmed.startsWith("- ") ? trimmed : `- ${trimmed}`; -} - -function sectionHeading(section: UnreleasedSection): string { - return section === "changes" ? "### Changes" : "### Fixes"; -} - -export function insertUnreleasedChangelogEntry( - changelogContent: string, - section: UnreleasedSection, - entry: string, -): string { - const normalizedEntry = normalizeEntry(entry); - const lines = changelogContent.split(/\r?\n/); - const unreleasedHeaderIndex = lines.findIndex((line) => - /^##\s+.+\s+\(Unreleased\)\s*$/.test(line.trim()), - ); - if (unreleasedHeaderIndex < 0) { - throw new Error("could not find an '(Unreleased)' changelog section"); - } - - const unreleasedEndIndex = lines.findIndex( - (line, index) => index > unreleasedHeaderIndex && /^##\s+/.test(line.trim()), - ); - const unreleasedLimit = unreleasedEndIndex < 0 ? lines.length : unreleasedEndIndex; - const sectionLabel = sectionHeading(section); - const sectionStartIndex = lines.findIndex( - (line, index) => - index > unreleasedHeaderIndex && index < unreleasedLimit && line.trim() === sectionLabel, - ); - if (sectionStartIndex < 0) { - throw new Error(`could not find '${sectionLabel}' under unreleased section`); - } - - const sectionEndIndex = lines.findIndex( - (line, index) => - index > sectionStartIndex && - index < unreleasedLimit && - (/^###\s+/.test(line.trim()) || /^##\s+/.test(line.trim())), - ); - const targetIndex = sectionEndIndex < 0 ? unreleasedLimit : sectionEndIndex; - let insertionIndex = targetIndex; - while (insertionIndex > sectionStartIndex + 1 && lines[insertionIndex - 1].trim() === "") { - insertionIndex -= 1; - } - - if ( - lines.slice(sectionStartIndex + 1, targetIndex).some((line) => line.trim() === normalizedEntry) - ) { - return changelogContent; - } - - lines.splice(insertionIndex, 0, normalizedEntry); - return `${lines.join("\n")}\n`; -} - -type CliArgs = { - section: UnreleasedSection; - entry: string; - file: string; -}; - -function parseCliArgs(argv: string[]): CliArgs { - let section: UnreleasedSection | null = null; - let entry = ""; - let file = "CHANGELOG.md"; - - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (arg === "--section") { - const value = argv[i + 1]; - if (value !== "changes" && value !== "fixes") { - throw new Error("--section must be one of: changes, fixes"); - } - section = value; - i += 1; - continue; - } - if (arg === "--entry") { - entry = argv[i + 1] ?? ""; - i += 1; - continue; - } - if (arg === "--file") { - file = argv[i + 1] ?? file; - i += 1; - continue; - } - throw new Error(`unknown argument: ${arg}`); - } - - if (!section) { - throw new Error("missing --section "); - } - if (!entry.trim()) { - throw new Error("missing --entry "); - } - return { section, entry, file }; -} - -function runCli(): void { - const args = parseCliArgs(process.argv.slice(2)); - const changelogPath = resolve(process.cwd(), args.file); - const content = readFileSync(changelogPath, "utf8"); - const next = insertUnreleasedChangelogEntry(content, args.section, args.entry); - if (next !== content) { - writeFileSync(changelogPath, next, "utf8"); - } -} - -if (import.meta.url === `file://${process.argv[1]}`) { - runCli(); -} diff --git a/test/scripts/changelog-add.test.ts b/test/scripts/changelog-add.test.ts deleted file mode 100644 index f9c0d4755d88..000000000000 --- a/test/scripts/changelog-add.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { insertUnreleasedChangelogEntry } from "../../scripts/changelog-add.ts"; - -const SAMPLE = `# Changelog - -## 2026.2.24 (Unreleased) - -### Changes - -- Existing change. - -### Fixes - -- Existing fix. - -## 2026.2.23 - -### Changes - -- Older entry. -`; - -describe("changelog-add", () => { - it("inserts a new unreleased fixes entry before the next version section", () => { - const next = insertUnreleasedChangelogEntry( - SAMPLE, - "fixes", - "New fix entry. (#123) Thanks @someone.", - ); - expect(next).toContain( - "- Existing fix.\n- New fix entry. (#123) Thanks @someone.\n\n## 2026.2.23", - ); - }); - - it("normalizes missing bullet prefix", () => { - const next = insertUnreleasedChangelogEntry(SAMPLE, "changes", "New change."); - expect(next).toContain("- Existing change.\n- New change.\n\n### Fixes"); - }); - - it("does not duplicate identical entry", () => { - const once = insertUnreleasedChangelogEntry(SAMPLE, "fixes", "New fix."); - const twice = insertUnreleasedChangelogEntry(once, "fixes", "New fix."); - expect(twice).toBe(once); - }); -}); From 36c352453fb0034bc445e24b4808648168755225 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 18:06:35 +0530 Subject: [PATCH 210/408] build(android): bump AGP and update gradle defaults --- apps/android/build.gradle.kts | 2 +- apps/android/gradle.properties | 10 ++++++++++ apps/android/gradle/gradle-daemon-jvm.properties | 12 ++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 apps/android/gradle/gradle-daemon-jvm.properties diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts index f79902d5615f..87252db452cc 100644 --- a/apps/android/build.gradle.kts +++ b/apps/android/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("com.android.application") version "8.13.2" apply false + id("com.android.application") version "9.0.1" apply false id("org.jetbrains.kotlin.android") version "2.2.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false diff --git a/apps/android/gradle.properties b/apps/android/gradle.properties index 5f84d966ee84..29f08fa7a090 100644 --- a/apps/android/gradle.properties +++ b/apps/android/gradle.properties @@ -3,3 +3,13 @@ org.gradle.warning.mode=none android.useAndroidX=true android.nonTransitiveRClass=true android.enableR8.fullMode=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false diff --git a/apps/android/gradle/gradle-daemon-jvm.properties b/apps/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 000000000000..6c1139ec06ae --- /dev/null +++ b/apps/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 From 3e2e010952e5d0c97ff6574f3d4cf5161d79c402 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 18:07:29 +0530 Subject: [PATCH 211/408] feat(android): add onboarding and gateway auth state plumbing --- .../main/java/ai/openclaw/android/MainViewModel.kt | 9 +++++++++ .../main/java/ai/openclaw/android/NodeRuntime.kt | 3 +++ .../main/java/ai/openclaw/android/SecurePrefs.kt | 13 +++++++++++++ 3 files changed, 25 insertions(+) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt index d9123d10293e..62f91cf624e7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt @@ -53,6 +53,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val manualPort: StateFlow = runtime.manualPort val manualTls: StateFlow = runtime.manualTls val gatewayToken: StateFlow = runtime.gatewayToken + val onboardingCompleted: StateFlow = runtime.onboardingCompleted val canvasDebugStatusEnabled: StateFlow = runtime.canvasDebugStatusEnabled val chatSessionKey: StateFlow = runtime.chatSessionKey @@ -110,6 +111,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.setGatewayToken(value) } + fun setGatewayPassword(value: String) { + runtime.setGatewayPassword(value) + } + + fun setOnboardingCompleted(value: Boolean) { + runtime.setOnboardingCompleted(value) + } + fun setCanvasDebugStatusEnabled(value: Boolean) { runtime.setCanvasDebugStatusEnabled(value) } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt index aec192c25bbc..fece62788647 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt @@ -345,7 +345,10 @@ class NodeRuntime(context: Context) { val manualPort: StateFlow = prefs.manualPort val manualTls: StateFlow = prefs.manualTls val gatewayToken: StateFlow = prefs.gatewayToken + val onboardingCompleted: StateFlow = prefs.onboardingCompleted fun setGatewayToken(value: String) = prefs.setGatewayToken(value) + fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value) + fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value) val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled diff --git a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt index 29ef4a3eaae1..e0cacd7a3ccc 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt @@ -75,6 +75,10 @@ class SecurePrefs(context: Context) { MutableStateFlow(prefs.getString("gateway.manual.token", "") ?: "") val gatewayToken: StateFlow = _gatewayToken + private val _onboardingCompleted = + MutableStateFlow(prefs.getBoolean("onboarding.completed", false)) + val onboardingCompleted: StateFlow = _onboardingCompleted + private val _lastDiscoveredStableId = MutableStateFlow( prefs.getString("gateway.lastDiscoveredStableID", "") ?: "", @@ -152,6 +156,15 @@ class SecurePrefs(context: Context) { _gatewayToken.value = value } + fun setGatewayPassword(value: String) { + saveGatewayPassword(value) + } + + fun setOnboardingCompleted(value: Boolean) { + prefs.edit { putBoolean("onboarding.completed", value) } + _onboardingCompleted.value = value + } + fun setCanvasDebugStatusEnabled(value: Boolean) { prefs.edit { putBoolean("canvas.debugStatusEnabled", value) } _canvasDebugStatusEnabled.value = value From b9cc2599f1e3adbb3c0eea1ca19254122526521b Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 18:07:32 +0530 Subject: [PATCH 212/408] feat(android): add native four-step onboarding flow --- .../THIRD_PARTY_LICENSES/MANROPE_OFL.txt | 93 ++ .../java/ai/openclaw/android/MainActivity.kt | 39 - .../ai/openclaw/android/ui/OnboardingFlow.kt | 1191 +++++++++++++++++ .../java/ai/openclaw/android/ui/RootScreen.kt | 7 + .../src/main/res/font/manrope_400_regular.ttf | Bin 0 -> 96832 bytes .../src/main/res/font/manrope_500_medium.ttf | Bin 0 -> 96904 bytes .../main/res/font/manrope_600_semibold.ttf | Bin 0 -> 96936 bytes .../src/main/res/font/manrope_700_bold.ttf | Bin 0 -> 96800 bytes 8 files changed, 1291 insertions(+), 39 deletions(-) create mode 100644 apps/android/THIRD_PARTY_LICENSES/MANROPE_OFL.txt create mode 100644 apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt create mode 100644 apps/android/app/src/main/res/font/manrope_400_regular.ttf create mode 100644 apps/android/app/src/main/res/font/manrope_500_medium.ttf create mode 100644 apps/android/app/src/main/res/font/manrope_600_semibold.ttf create mode 100644 apps/android/app/src/main/res/font/manrope_700_bold.ttf diff --git a/apps/android/THIRD_PARTY_LICENSES/MANROPE_OFL.txt b/apps/android/THIRD_PARTY_LICENSES/MANROPE_OFL.txt new file mode 100644 index 000000000000..472064afc4b8 --- /dev/null +++ b/apps/android/THIRD_PARTY_LICENSES/MANROPE_OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt b/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt index 2bbfd8712f92..cafe0958f86a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt @@ -1,9 +1,7 @@ package ai.openclaw.android -import android.Manifest import android.content.pm.ApplicationInfo import android.os.Bundle -import android.os.Build import android.view.WindowManager import android.webkit.WebView import androidx.activity.ComponentActivity @@ -11,7 +9,6 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.material3.Surface import androidx.compose.ui.Modifier -import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -32,8 +29,6 @@ class MainActivity : ComponentActivity() { val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 WebView.setWebContentsDebuggingEnabled(isDebuggable) applyImmersiveMode() - requestDiscoveryPermissionsIfNeeded() - requestNotificationPermissionIfNeeded() NodeForegroundService.start(this) permissionRequester = PermissionRequester(this) screenCaptureRequester = ScreenCaptureRequester(this) @@ -93,38 +88,4 @@ class MainActivity : ComponentActivity() { WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE controller.hide(WindowInsetsCompat.Type.systemBars()) } - - private fun requestDiscoveryPermissionsIfNeeded() { - if (Build.VERSION.SDK_INT >= 33) { - val ok = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.NEARBY_WIFI_DEVICES, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (!ok) { - requestPermissions(arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES), 100) - } - } else { - val ok = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.ACCESS_FINE_LOCATION, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (!ok) { - requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 101) - } - } - } - - private fun requestNotificationPermissionIfNeeded() { - if (Build.VERSION.SDK_INT < 33) return - val ok = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.POST_NOTIFICATIONS, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (!ok) { - requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 102) - } - } } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt new file mode 100644 index 000000000000..d35cab59f549 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt @@ -0,0 +1,1191 @@ +package ai.openclaw.android.ui + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.util.Base64 +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import ai.openclaw.android.LocationMode +import ai.openclaw.android.MainViewModel +import ai.openclaw.android.R +import java.util.Locale +import org.json.JSONObject + +private enum class OnboardingStep(val index: Int, val label: String) { + Welcome(1, "Welcome"), + Gateway(2, "Gateway"), + Permissions(3, "Permissions"), + FinalCheck(4, "Connect"), +} + +private enum class GatewayInputMode { + SetupCode, + Manual, +} + +private data class ParsedGateway( + val host: String, + val port: Int, + val tls: Boolean, + val displayUrl: String, +) + +private data class SetupCodePayload( + val url: String, + val token: String?, + val password: String?, +) + +private val onboardingBackgroundGradient = + listOf( + Color(0xFFFFFFFF), + Color(0xFFF7F8FA), + Color(0xFFEFF1F5), + ) +private val onboardingSurface = Color(0xFFF6F7FA) +private val onboardingBorder = Color(0xFFE5E7EC) +private val onboardingBorderStrong = Color(0xFFD6DAE2) +private val onboardingText = Color(0xFF17181C) +private val onboardingTextSecondary = Color(0xFF4D5563) +private val onboardingTextTertiary = Color(0xFF8A92A2) +private val onboardingAccent = Color(0xFF1D5DD8) +private val onboardingAccentSoft = Color(0xFFECF3FF) +private val onboardingSuccess = Color(0xFF2F8C5A) +private val onboardingWarning = Color(0xFFC8841A) +private val onboardingCommandBg = Color(0xFF15171B) +private val onboardingCommandBorder = Color(0xFF2B2E35) +private val onboardingCommandAccent = Color(0xFF3FC97A) +private val onboardingCommandText = Color(0xFFE8EAEE) + +private val onboardingFontFamily = + FontFamily( + Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal), + Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium), + Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold), + Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold), + ) + +private val onboardingDisplayStyle = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 34.sp, + lineHeight = 40.sp, + letterSpacing = (-0.8).sp, + ) + +private val onboardingTitle1Style = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 30.sp, + letterSpacing = (-0.5).sp, + ) + +private val onboardingHeadlineStyle = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 22.sp, + letterSpacing = (-0.1).sp, + ) + +private val onboardingBodyStyle = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 22.sp, + ) + +private val onboardingCalloutStyle = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + ) + +private val onboardingCaption1Style = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.2.sp, + ) + +private val onboardingCaption2Style = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 14.sp, + letterSpacing = 0.4.sp, + ) + +@Composable +fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { + val context = androidx.compose.ui.platform.LocalContext.current + val statusText by viewModel.statusText.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + val serverName by viewModel.serverName.collectAsState() + val remoteAddress by viewModel.remoteAddress.collectAsState() + val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() + + var step by rememberSaveable { mutableStateOf(OnboardingStep.Welcome) } + var setupCode by rememberSaveable { mutableStateOf("") } + var gatewayUrl by rememberSaveable { mutableStateOf("") } + var gatewayToken by rememberSaveable { mutableStateOf("") } + var gatewayPassword by rememberSaveable { mutableStateOf("") } + var gatewayInputMode by rememberSaveable { mutableStateOf(GatewayInputMode.SetupCode) } + var manualHost by rememberSaveable { mutableStateOf("10.0.2.2") } + var manualPort by rememberSaveable { mutableStateOf("18789") } + var manualTls by rememberSaveable { mutableStateOf(false) } + var gatewayError by rememberSaveable { mutableStateOf(null) } + var attemptedConnect by rememberSaveable { mutableStateOf(false) } + + var enableDiscovery by rememberSaveable { mutableStateOf(true) } + var enableNotifications by rememberSaveable { mutableStateOf(true) } + var enableMicrophone by rememberSaveable { mutableStateOf(false) } + var enableCamera by rememberSaveable { mutableStateOf(false) } + var enableSms by rememberSaveable { mutableStateOf(false) } + + val smsAvailable = + remember(context) { + context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true + } + + val selectedPermissions = + remember( + context, + enableDiscovery, + enableNotifications, + enableMicrophone, + enableCamera, + enableSms, + smsAvailable, + ) { + val requested = mutableListOf() + if (enableDiscovery) { + requested += if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION + } + if (enableNotifications && Build.VERSION.SDK_INT >= 33) requested += Manifest.permission.POST_NOTIFICATIONS + if (enableMicrophone) requested += Manifest.permission.RECORD_AUDIO + if (enableCamera) requested += Manifest.permission.CAMERA + if (enableSms && smsAvailable) requested += Manifest.permission.SEND_SMS + requested.filterNot { isPermissionGranted(context, it) } + } + + val enabledPermissionSummary = + remember(enableDiscovery, enableNotifications, enableMicrophone, enableCamera, enableSms, smsAvailable) { + val enabled = mutableListOf() + if (enableDiscovery) enabled += "Gateway discovery" + if (Build.VERSION.SDK_INT >= 33 && enableNotifications) enabled += "Notifications" + if (enableMicrophone) enabled += "Microphone" + if (enableCamera) enabled += "Camera" + if (smsAvailable && enableSms) enabled += "SMS" + if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ") + } + + val permissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + step = OnboardingStep.FinalCheck + } + + if (pendingTrust != null) { + val prompt = pendingTrust!! + AlertDialog( + onDismissRequest = { viewModel.declineGatewayTrustPrompt() }, + title = { Text("Trust this gateway?") }, + text = { + Text( + "First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}", + ) + }, + confirmButton = { + TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) { + Text("Trust and continue") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) { + Text("Cancel") + } + }, + ) + } + + Box( + modifier = + modifier + .fillMaxSize() + .background(Brush.verticalGradient(onboardingBackgroundGradient)), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .imePadding() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)) + .navigationBarsPadding() + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Column( + modifier = Modifier.padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "FIRST RUN", + style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp), + color = onboardingAccent, + ) + Text( + "OpenClaw\nMobile Setup", + style = onboardingDisplayStyle.copy(lineHeight = 38.sp), + color = onboardingText, + ) + Text( + "Step ${step.index} of 4", + style = onboardingCaption1Style, + color = onboardingAccent, + ) + } + StepRailWrap(current = step) + + when (step) { + OnboardingStep.Welcome -> WelcomeStep() + OnboardingStep.Gateway -> + GatewayStep( + inputMode = gatewayInputMode, + setupCode = setupCode, + manualHost = manualHost, + manualPort = manualPort, + manualTls = manualTls, + gatewayToken = gatewayToken, + gatewayPassword = gatewayPassword, + gatewayError = gatewayError, + onInputModeChange = { + gatewayInputMode = it + gatewayError = null + }, + onSetupCodeChange = { + setupCode = it + gatewayError = null + }, + onManualHostChange = { + manualHost = it + gatewayError = null + }, + onManualPortChange = { + manualPort = it + gatewayError = null + }, + onManualTlsChange = { manualTls = it }, + onTokenChange = { gatewayToken = it }, + onPasswordChange = { gatewayPassword = it }, + ) + OnboardingStep.Permissions -> + PermissionsStep( + enableDiscovery = enableDiscovery, + enableNotifications = enableNotifications, + enableMicrophone = enableMicrophone, + enableCamera = enableCamera, + enableSms = enableSms, + smsAvailable = smsAvailable, + context = context, + onDiscoveryChange = { enableDiscovery = it }, + onNotificationsChange = { enableNotifications = it }, + onMicrophoneChange = { enableMicrophone = it }, + onCameraChange = { enableCamera = it }, + onSmsChange = { enableSms = it }, + ) + OnboardingStep.FinalCheck -> + FinalStep( + parsedGateway = parseGateway(gatewayUrl), + statusText = statusText, + isConnected = isConnected, + serverName = serverName, + remoteAddress = remoteAddress, + attemptedConnect = attemptedConnect, + enabledPermissions = enabledPermissionSummary, + methodLabel = if (gatewayInputMode == GatewayInputMode.SetupCode) "Setup Code" else "Manual", + ) + } + } + + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val backEnabled = step != OnboardingStep.Welcome + Surface( + modifier = Modifier.size(52.dp), + shape = RoundedCornerShape(14.dp), + color = onboardingSurface, + border = androidx.compose.foundation.BorderStroke(1.dp, if (backEnabled) onboardingBorderStrong else onboardingBorder), + ) { + IconButton( + onClick = { + step = + when (step) { + OnboardingStep.Welcome -> OnboardingStep.Welcome + OnboardingStep.Gateway -> OnboardingStep.Welcome + OnboardingStep.Permissions -> OnboardingStep.Gateway + OnboardingStep.FinalCheck -> OnboardingStep.Permissions + } + }, + enabled = backEnabled, + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = if (backEnabled) onboardingTextSecondary else onboardingTextTertiary, + ) + } + } + + when (step) { + OnboardingStep.Welcome -> { + Button( + onClick = { step = OnboardingStep.Gateway }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } + OnboardingStep.Gateway -> { + Button( + onClick = { + if (gatewayInputMode == GatewayInputMode.SetupCode) { + val parsedSetup = decodeSetupCode(setupCode) + if (parsedSetup == null) { + gatewayError = "Invalid setup code." + return@Button + } + val parsedGateway = parseGateway(parsedSetup.url) + if (parsedGateway == null) { + gatewayError = "Setup code has invalid gateway URL." + return@Button + } + gatewayUrl = parsedSetup.url + gatewayToken = parsedSetup.token.orEmpty() + gatewayPassword = parsedSetup.password.orEmpty() + } else { + val manualUrl = composeManualGatewayUrl(manualHost, manualPort, manualTls) + val parsedGateway = manualUrl?.let(::parseGateway) + if (parsedGateway == null) { + gatewayError = "Manual endpoint is invalid." + return@Button + } + gatewayUrl = parsedGateway.displayUrl + } + step = OnboardingStep.Permissions + }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } + OnboardingStep.Permissions -> { + Button( + onClick = { + viewModel.setCameraEnabled(enableCamera) + viewModel.setLocationMode(if (enableDiscovery) LocationMode.WhileUsing else LocationMode.Off) + if (selectedPermissions.isEmpty()) { + step = OnboardingStep.FinalCheck + } else { + permissionLauncher.launch(selectedPermissions.toTypedArray()) + } + }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } + OnboardingStep.FinalCheck -> { + if (isConnected) { + Button( + onClick = { viewModel.setOnboardingCompleted(true) }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Finish", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } else { + Button( + onClick = { + val parsed = parseGateway(gatewayUrl) + if (parsed == null) { + step = OnboardingStep.Gateway + gatewayError = "Invalid gateway URL." + return@Button + } + val token = gatewayToken.trim() + val password = gatewayPassword.trim() + attemptedConnect = true + viewModel.setManualEnabled(true) + viewModel.setManualHost(parsed.host) + viewModel.setManualPort(parsed.port) + viewModel.setManualTls(parsed.tls) + viewModel.setGatewayToken(token) + viewModel.setGatewayPassword(password) + viewModel.connectManual() + }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Connect", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } + } + } + } + } + } +} + +@Composable +private fun StepRailWrap(current: OnboardingStep) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + HorizontalDivider(color = onboardingBorder) + StepRail(current = current) + HorizontalDivider(color = onboardingBorder) + } +} + +@Composable +private fun StepRail(current: OnboardingStep) { + val steps = OnboardingStep.entries + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + steps.forEach { step -> + val complete = step.index < current.index + val active = step.index == current.index + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(5.dp) + .background( + color = + when { + complete -> onboardingSuccess + active -> onboardingAccent + else -> onboardingBorder + }, + shape = RoundedCornerShape(999.dp), + ), + ) + Text( + text = step.label, + style = onboardingCaption2Style.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold), + color = if (active) onboardingAccent else onboardingTextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun WelcomeStep() { + StepShell(title = "What You Get") { + Bullet("Control the gateway and operator chat from one mobile surface.") + Bullet("Connect with setup code and recover pairing with CLI commands.") + Bullet("Enable only the permissions and capabilities you want.") + Bullet("Finish with a real connection check before entering the app.") + } +} + +@Composable +private fun GatewayStep( + inputMode: GatewayInputMode, + setupCode: String, + manualHost: String, + manualPort: String, + manualTls: Boolean, + gatewayToken: String, + gatewayPassword: String, + gatewayError: String?, + onInputModeChange: (GatewayInputMode) -> Unit, + onSetupCodeChange: (String) -> Unit, + onManualHostChange: (String) -> Unit, + onManualPortChange: (String) -> Unit, + onManualTlsChange: (Boolean) -> Unit, + onTokenChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, +) { + val resolvedEndpoint = remember(setupCode) { decodeSetupCode(setupCode)?.url?.let { parseGateway(it)?.displayUrl } } + val manualResolvedEndpoint = remember(manualHost, manualPort, manualTls) { composeManualGatewayUrl(manualHost, manualPort, manualTls)?.let { parseGateway(it)?.displayUrl } } + + StepShell(title = "Gateway Connection") { + GuideBlock(title = "Get setup code + gateway URL") { + Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary) + CommandBlock("openclaw qr --setup-code-only") + CommandBlock("openclaw qr --json") + Text( + "`--json` prints `setupCode` and `gatewayUrl`.", + style = onboardingCalloutStyle, + color = onboardingTextSecondary, + ) + Text( + "Auto URL discovery is not wired yet. Android emulator uses `10.0.2.2`; real devices need LAN/Tailscale host.", + style = onboardingCalloutStyle, + color = onboardingTextSecondary, + ) + } + GatewayModeToggle(inputMode = inputMode, onInputModeChange = onInputModeChange) + + if (inputMode == GatewayInputMode.SetupCode) { + Text("SETUP CODE", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = setupCode, + onValueChange = onSetupCodeChange, + placeholder = { Text("Paste code from `openclaw qr --setup-code-only`", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 5, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + if (!resolvedEndpoint.isNullOrBlank()) { + ResolvedEndpoint(endpoint = resolvedEndpoint) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + QuickFillChip(label = "Android Emulator", onClick = { + onManualHostChange("10.0.2.2") + onManualPortChange("18789") + onManualTlsChange(false) + }) + QuickFillChip(label = "Localhost", onClick = { + onManualHostChange("127.0.0.1") + onManualPortChange("18789") + onManualTlsChange(false) + }) + } + + Text("HOST", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = manualHost, + onValueChange = onManualHostChange, + placeholder = { Text("10.0.2.2", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + textStyle = onboardingBodyStyle.copy(color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + + Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = manualPort, + onValueChange = onManualPortChange, + placeholder = { Text("18789", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Use TLS", style = onboardingHeadlineStyle, color = onboardingText) + Text("Switch to secure websocket (`wss`).", style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary) + } + Switch( + checked = manualTls, + onCheckedChange = onManualTlsChange, + colors = + SwitchDefaults.colors( + checkedTrackColor = onboardingAccent, + uncheckedTrackColor = onboardingBorderStrong, + checkedThumbColor = Color.White, + uncheckedThumbColor = Color.White, + ), + ) + } + + Text("TOKEN (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = gatewayToken, + onValueChange = onTokenChange, + placeholder = { Text("token", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = onboardingBodyStyle.copy(color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + + Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = gatewayPassword, + onValueChange = onPasswordChange, + placeholder = { Text("password", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = onboardingBodyStyle.copy(color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + + if (!manualResolvedEndpoint.isNullOrBlank()) { + ResolvedEndpoint(endpoint = manualResolvedEndpoint) + } + } + + if (!gatewayError.isNullOrBlank()) { + Text(gatewayError, color = onboardingWarning, style = onboardingCaption1Style) + } + } +} + +@Composable +private fun GuideBlock( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Row(modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(modifier = Modifier.width(2.dp).fillMaxHeight().background(onboardingAccent.copy(alpha = 0.4f))) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = onboardingHeadlineStyle, color = onboardingText) + content() + } + } +} + +@Composable +private fun GatewayModeToggle( + inputMode: GatewayInputMode, + onInputModeChange: (GatewayInputMode) -> Unit, +) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + GatewayModeChip( + label = "Setup Code", + active = inputMode == GatewayInputMode.SetupCode, + onClick = { onInputModeChange(GatewayInputMode.SetupCode) }, + modifier = Modifier.weight(1f), + ) + GatewayModeChip( + label = "Manual", + active = inputMode == GatewayInputMode.Manual, + onClick = { onInputModeChange(GatewayInputMode.Manual) }, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun GatewayModeChip( + label: String, + active: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Button( + onClick = onClick, + modifier = modifier.height(40.dp), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (active) onboardingAccent else onboardingSurface, + contentColor = if (active) Color.White else onboardingText, + ), + border = androidx.compose.foundation.BorderStroke(1.dp, if (active) Color(0xFF184DAF) else onboardingBorderStrong), + ) { + Text( + text = label, + style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold), + ) + } +} + +@Composable +private fun QuickFillChip( + label: String, + onClick: () -> Unit, +) { + TextButton( + onClick = onClick, + shape = RoundedCornerShape(999.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 7.dp), + colors = + ButtonDefaults.textButtonColors( + containerColor = onboardingAccentSoft, + contentColor = onboardingAccent, + ), + ) { + Text(label, style = onboardingCaption1Style.copy(fontWeight = FontWeight.SemiBold)) + } +} + +@Composable +private fun ResolvedEndpoint(endpoint: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + HorizontalDivider(color = onboardingBorder) + Text( + "RESOLVED ENDPOINT", + style = onboardingCaption2Style.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.7.sp), + color = onboardingTextSecondary, + ) + Text( + endpoint, + style = onboardingCalloutStyle.copy(fontFamily = FontFamily.Monospace), + color = onboardingText, + ) + HorizontalDivider(color = onboardingBorder) + } +} + +@Composable +private fun StepShell( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { + HorizontalDivider(color = onboardingBorder) + Column(modifier = Modifier.padding(vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(title, style = onboardingTitle1Style, color = onboardingText) + content() + } + HorizontalDivider(color = onboardingBorder) + } +} + +@Composable +private fun InlineDivider() { + HorizontalDivider(color = onboardingBorder) +} + +@Composable +private fun PermissionsStep( + enableDiscovery: Boolean, + enableNotifications: Boolean, + enableMicrophone: Boolean, + enableCamera: Boolean, + enableSms: Boolean, + smsAvailable: Boolean, + context: Context, + onDiscoveryChange: (Boolean) -> Unit, + onNotificationsChange: (Boolean) -> Unit, + onMicrophoneChange: (Boolean) -> Unit, + onCameraChange: (Boolean) -> Unit, + onSmsChange: (Boolean) -> Unit, +) { + val discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION + StepShell(title = "Permissions") { + Text( + "Enable only what you need now. You can change everything later in Settings.", + style = onboardingCalloutStyle, + color = onboardingTextSecondary, + ) + PermissionToggleRow( + title = "Gateway discovery", + subtitle = if (Build.VERSION.SDK_INT >= 33) "Nearby devices" else "Location (for NSD)", + checked = enableDiscovery, + granted = isPermissionGranted(context, discoveryPermission), + onCheckedChange = onDiscoveryChange, + ) + InlineDivider() + if (Build.VERSION.SDK_INT >= 33) { + PermissionToggleRow( + title = "Notifications", + subtitle = "Foreground service + alerts", + checked = enableNotifications, + granted = isPermissionGranted(context, Manifest.permission.POST_NOTIFICATIONS), + onCheckedChange = onNotificationsChange, + ) + InlineDivider() + } + PermissionToggleRow( + title = "Microphone", + subtitle = "Talk mode + voice features", + checked = enableMicrophone, + granted = isPermissionGranted(context, Manifest.permission.RECORD_AUDIO), + onCheckedChange = onMicrophoneChange, + ) + InlineDivider() + PermissionToggleRow( + title = "Camera", + subtitle = "camera.snap and camera.clip", + checked = enableCamera, + granted = isPermissionGranted(context, Manifest.permission.CAMERA), + onCheckedChange = onCameraChange, + ) + if (smsAvailable) { + InlineDivider() + PermissionToggleRow( + title = "SMS", + subtitle = "Allow gateway-triggered SMS sending", + checked = enableSms, + granted = isPermissionGranted(context, Manifest.permission.SEND_SMS), + onCheckedChange = onSmsChange, + ) + } + Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary) + } +} + +@Composable +private fun PermissionToggleRow( + title: String, + subtitle: String, + checked: Boolean, + granted: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 50.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = onboardingHeadlineStyle, color = onboardingText) + Text(subtitle, style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary) + Text( + if (granted) "Granted" else "Not granted", + style = onboardingCaption1Style, + color = if (granted) onboardingSuccess else onboardingTextSecondary, + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = + SwitchDefaults.colors( + checkedTrackColor = onboardingAccent, + uncheckedTrackColor = onboardingBorderStrong, + checkedThumbColor = Color.White, + uncheckedThumbColor = Color.White, + ), + ) + } +} + +@Composable +private fun FinalStep( + parsedGateway: ParsedGateway?, + statusText: String, + isConnected: Boolean, + serverName: String?, + remoteAddress: String?, + attemptedConnect: Boolean, + enabledPermissions: String, + methodLabel: String, +) { + StepShell(title = "Review") { + SummaryField(label = "Method", value = methodLabel) + SummaryField(label = "Gateway", value = parsedGateway?.displayUrl ?: "Invalid gateway URL") + SummaryField(label = "Enabled Permissions", value = enabledPermissions) + + if (!attemptedConnect) { + Text("Press Connect to verify gateway reachability and auth.", style = onboardingCalloutStyle, color = onboardingTextSecondary) + } else { + Text("Status: $statusText", style = onboardingCalloutStyle, color = if (isConnected) onboardingSuccess else onboardingTextSecondary) + if (isConnected) { + Text("Connected to ${serverName ?: remoteAddress ?: "gateway"}", style = onboardingCalloutStyle, color = onboardingSuccess) + } else { + GuideBlock(title = "Pairing Required") { + Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary) + CommandBlock("openclaw nodes pending") + CommandBlock("openclaw nodes approve ") + Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary) + } + } + } + } +} + +@Composable +private fun SummaryField(label: String, value: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + label, + style = onboardingCaption2Style.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp), + color = onboardingTextSecondary, + ) + Text(value, style = onboardingHeadlineStyle, color = onboardingText) + HorizontalDivider(color = onboardingBorder) + } +} + +@Composable +private fun CommandBlock(command: String) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(onboardingCommandBg, RoundedCornerShape(12.dp)) + .border(width = 1.dp, color = onboardingCommandBorder, shape = RoundedCornerShape(12.dp)), + ) { + Box(modifier = Modifier.width(3.dp).height(42.dp).background(onboardingCommandAccent)) + Text( + command, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = onboardingCalloutStyle, + fontFamily = FontFamily.Monospace, + color = onboardingCommandText, + ) + } +} + +@Composable +private fun Bullet(text: String) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) { + Box( + modifier = + Modifier + .padding(top = 7.dp) + .size(8.dp) + .background(onboardingAccentSoft, CircleShape), + ) + Box( + modifier = + Modifier + .padding(top = 9.dp) + .size(4.dp) + .background(onboardingAccent, CircleShape), + ) + Text(text, style = onboardingBodyStyle, color = onboardingTextSecondary, modifier = Modifier.weight(1f)) + } +} + +private fun parseGateway(rawInput: String): ParsedGateway? { + val raw = rawInput.trim() + if (raw.isEmpty()) return null + + val normalized = if (raw.contains("://")) raw else "https://$raw" + val uri = normalized.toUri() + val host = uri.host?.trim().orEmpty() + if (host.isEmpty()) return null + + val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty() + val tls = + when (scheme) { + "ws", "http" -> false + "wss", "https" -> true + else -> true + } + val port = uri.port.takeIf { it in 1..65535 } ?: 18789 + val displayUrl = "${if (tls) "https" else "http"}://$host:$port" + + return ParsedGateway(host = host, port = port, tls = tls, displayUrl = displayUrl) +} + +private fun decodeSetupCode(rawInput: String): SetupCodePayload? { + val trimmed = rawInput.trim() + if (trimmed.isEmpty()) return null + + val padded = + trimmed + .replace('-', '+') + .replace('_', '/') + .let { normalized -> + val remainder = normalized.length % 4 + if (remainder == 0) normalized else normalized + "=".repeat(4 - remainder) + } + + return try { + val decoded = String(Base64.decode(padded, Base64.DEFAULT), Charsets.UTF_8) + val obj = JSONObject(decoded) + val url = obj.optString("url").trim() + if (url.isEmpty()) return null + val token = obj.optString("token").trim().ifEmpty { null } + val password = obj.optString("password").trim().ifEmpty { null } + SetupCodePayload(url = url, token = token, password = password) + } catch (_: Throwable) { + null + } +} + +private fun isPermissionGranted(context: Context, permission: String): Boolean { + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED +} + +private fun composeManualGatewayUrl(hostInput: String, portInput: String, tls: Boolean): String? { + val host = hostInput.trim() + val port = portInput.trim().toIntOrNull() ?: return null + if (host.isEmpty() || port !in 1..65535) return null + val scheme = if (tls) "https" else "http" + return "$scheme://$host:$port" +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt index af0cfe628ac0..38440ac5a7c9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt @@ -75,6 +75,13 @@ fun RootScreen(viewModel: MainViewModel) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) val context = LocalContext.current + val onboardingCompleted by viewModel.onboardingCompleted.collectAsState() + + if (!onboardingCompleted) { + OnboardingFlow(viewModel = viewModel, modifier = Modifier.fillMaxSize()) + return + } + val serverName by viewModel.serverName.collectAsState() val statusText by viewModel.statusText.collectAsState() val cameraHud by viewModel.cameraHud.collectAsState() diff --git a/apps/android/app/src/main/res/font/manrope_400_regular.ttf b/apps/android/app/src/main/res/font/manrope_400_regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9a108f1cee9d7b9f52e9427d4a9105db838566ac GIT binary patch literal 96832 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 QI<01StVU#a{}Zq6A7-l(L;wH) literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/font/manrope_500_medium.ttf b/apps/android/app/src/main/res/font/manrope_500_medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c6d28def6d565eaa53a76eb39d3fa9d9e908709a GIT binary patch literal 96904 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 So@LBn6aeac|8FK#Cj$T&UW(lS literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/font/manrope_600_semibold.ttf b/apps/android/app/src/main/res/font/manrope_600_semibold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..46a13d61989936eaba29502ec364048f246ec2e1 GIT binary patch literal 96936 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 z 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 literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/font/manrope_700_bold.ttf b/apps/android/app/src/main/res/font/manrope_700_bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..62a618393905652a9696e015386f431b21a1a50b GIT binary patch literal 96800 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�Sq=K;W>my*Ny_rw9I(*8$zllSC8jm)m^F|t&Xjx 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

4Cz**@6c)hqyhN69DH65 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 M*<$A3PvX)24TIUt#{d8T literal 0 HcmV?d00001 From d6bbe93d4cc11153527035657f7e7afb925542b9 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 18:07:35 +0530 Subject: [PATCH 213/408] feat(android): add settings action to rerun onboarding --- .../app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt index bb04c30108ce..ad5a891e17b5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt @@ -458,6 +458,10 @@ fun SettingsSheet(viewModel: MainViewModel) { ) { Text("Connect (Manual)") } + + TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) { + Text("Run onboarding again") + } } } } From 5d88e7742086b5684b01fda2ef8c44148280f46d Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 18:07:50 +0530 Subject: [PATCH 214/408] docs(android): add native UI style guide --- apps/android/style.md | 119 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 apps/android/style.md diff --git a/apps/android/style.md b/apps/android/style.md new file mode 100644 index 000000000000..8cbe922c02e6 --- /dev/null +++ b/apps/android/style.md @@ -0,0 +1,119 @@ +# OpenClaw Android UI Style Guide + +Scope: `apps/android` native app (Kotlin + Jetpack Compose). +Goal: cohesive, high-clarity UI with deterministic behavior. + +## 1. Design Direction + +- Utility first: each screen has one obvious primary action. +- Calm surface: strong text contrast, restrained accents, minimal chrome. +- Progressive disclosure: advanced controls behind explicit affordances. +- Deterministic flow: validate early, block invalid progression, no hidden state. + +## 2. Source Of Truth + +Design and UI behavior anchors: + +- `app/src/main/java/ai/openclaw/android/ui/OpenClawTheme.kt` +- `app/src/main/java/ai/openclaw/android/ui/RootScreen.kt` +- `app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt` +- `app/src/main/java/ai/openclaw/android/ui/chat/*` +- `app/src/main/java/ai/openclaw/android/MainViewModel.kt` + +If design changes, update shared theme/primitives first, then feature screens. + +## 3. Tokens And Theming + +Do not introduce ad-hoc style values in feature composables. + +### Color + Typography + +- Prefer `MaterialTheme.colorScheme` and `MaterialTheme.typography`. +- Reuse `overlayContainerColor()` and `overlayIconColor()` for overlay controls. +- Avoid raw `Color(...)` literals except explicit semantic state cues. +- Keep text hierarchy clear: headline/body/supporting label styles, no random font sizes. + +### Spacing + Shape + +- Keep spacing rhythm consistent (`8/10/12/16/20.dp`). +- Prefer section grouping via spacing/dividers before adding card containers. +- Use elevation sparingly; only where interaction hierarchy needs it. + +## 4. Layout System + +- Base layout: `Box`/`Column` with `WindowInsets.safeDrawing` handling. +- Keep overlays above `AndroidView` content when touch priority matters. +- Structure by intent: + - status/hero + - core controls + - optional advanced controls +- Prefer rails/dividers over card stacks. + +## 5. Compose Architecture Rules + +- State hoisting: + - durable state in `MainViewModel` + - composables receive state + callbacks +- Composable APIs: + - include `modifier: Modifier = Modifier` + - avoid hidden global state +- Side effects: + - use `LaunchedEffect` / activity result APIs + - no blocking work in composition +- Recomposition hygiene: + - `remember`/derived values for computed UI state + - avoid allocating heavy objects on every recomposition + +## 6. Component Rules + +### Primary / secondary actions + +- One dominant primary action per context. +- Secondary actions visibly lower emphasis. +- Avoid duplicate actions that perform the same deterministic step. + +### Inputs + controls + +- Clear labels + concise helper text. +- Keep compact fields side-by-side only when both are short and related. +- Advanced settings collapsed by default. + +### WebView and mixed UI + +- Keep WebView behavior encapsulated in `AndroidView` wrappers. +- Guard verbose logs and diagnostics behind `BuildConfig.DEBUG`. + +## 7. Copy Style + +- Short, operational, direct. +- One helper sentence when possible. +- No repeated status messaging in multiple UI regions. +- Remove filler subtitles that do not change user action. + +## 8. Accessibility + Usability + +- Touch targets >= 44dp where practical. +- Do not rely on color alone for state. +- Provide meaningful `contentDescription` for icon-only controls. +- Preserve contrast for status, secondary text, and disabled states. + +## 9. Anti-Patterns (Do Not Add) + +- Hardcoded theme values sprinkled across screens. +- Business/network logic inside composables. +- Card-inside-card nesting for simple layout. +- Duplicate status pills/header messages for same state. +- Long unbounded helper prose under every control. + +## 10. New Screen Checklist + +1. Uses shared theme primitives (`MaterialTheme`, existing helpers), no random style constants. +2. Keeps durable state in ViewModel; UI is state + callbacks. +3. Has one clear primary action. +4. Uses spacing/divider-led hierarchy; cards only when needed. +5. Advanced controls collapsed by default. +6. Copy is concise; no duplicate status text. +7. Insets and touch targets are correct. +8. `./gradlew :app:lintDebug` passes. +9. `./gradlew :app:testDebugUnitTest` passes for touched logic. +10. Visual check on API 35 phone emulator and API 31 compatibility emulator. From 757a4dc9faec10141fd6ba0435a8a63203c2ad1e Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 18:12:04 +0530 Subject: [PATCH 215/408] docs(android): generalize style guide from onboarding baseline --- apps/android/style.md | 166 ++++++++++++++++++++---------------------- 1 file changed, 80 insertions(+), 86 deletions(-) diff --git a/apps/android/style.md b/apps/android/style.md index 8cbe922c02e6..f2b892ac6ff8 100644 --- a/apps/android/style.md +++ b/apps/android/style.md @@ -1,119 +1,113 @@ # OpenClaw Android UI Style Guide -Scope: `apps/android` native app (Kotlin + Jetpack Compose). -Goal: cohesive, high-clarity UI with deterministic behavior. +Scope: all native Android UI in `apps/android` (Jetpack Compose). +Goal: one coherent visual system across onboarding, settings, and future screens. ## 1. Design Direction -- Utility first: each screen has one obvious primary action. -- Calm surface: strong text contrast, restrained accents, minimal chrome. -- Progressive disclosure: advanced controls behind explicit affordances. -- Deterministic flow: validate early, block invalid progression, no hidden state. +- Clean, quiet surfaces. +- Strong readability first. +- One clear primary action per screen state. +- Progressive disclosure for advanced controls. +- Deterministic flows: validate early, fail clearly. -## 2. Source Of Truth +## 2. Style Baseline -Design and UI behavior anchors: +The onboarding flow defines the current visual baseline. +New screens should match that language unless there is a strong product reason not to. -- `app/src/main/java/ai/openclaw/android/ui/OpenClawTheme.kt` -- `app/src/main/java/ai/openclaw/android/ui/RootScreen.kt` -- `app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt` -- `app/src/main/java/ai/openclaw/android/ui/chat/*` -- `app/src/main/java/ai/openclaw/android/MainViewModel.kt` - -If design changes, update shared theme/primitives first, then feature screens. +Baseline traits: -## 3. Tokens And Theming +- Light neutral background with subtle depth. +- Clear blue accent for active/primary states. +- Strong border hierarchy for structure. +- Medium/semibold typography (no thin text). +- Divider-and-spacing layout over heavy card nesting. -Do not introduce ad-hoc style values in feature composables. +## 3. Core Tokens -### Color + Typography +Use these as shared design tokens for new Compose UI. -- Prefer `MaterialTheme.colorScheme` and `MaterialTheme.typography`. -- Reuse `overlayContainerColor()` and `overlayIconColor()` for overlay controls. -- Avoid raw `Color(...)` literals except explicit semantic state cues. -- Keep text hierarchy clear: headline/body/supporting label styles, no random font sizes. +- Background gradient: `#FFFFFF`, `#F7F8FA`, `#EFF1F5` +- Surface: `#F6F7FA` +- Border: `#E5E7EC` +- Border strong: `#D6DAE2` +- Text primary: `#17181C` +- Text secondary: `#4D5563` +- Text tertiary: `#8A92A2` +- Accent primary: `#1D5DD8` +- Accent soft: `#ECF3FF` +- Success: `#2F8C5A` +- Warning: `#C8841A` -### Spacing + Shape +Rule: do not introduce random per-screen colors when an existing token fits. -- Keep spacing rhythm consistent (`8/10/12/16/20.dp`). -- Prefer section grouping via spacing/dividers before adding card containers. -- Use elevation sparingly; only where interaction hierarchy needs it. +## 4. Typography -## 4. Layout System +Primary type family: Manrope (`400/500/600/700`). -- Base layout: `Box`/`Column` with `WindowInsets.safeDrawing` handling. -- Keep overlays above `AndroidView` content when touch priority matters. -- Structure by intent: - - status/hero - - core controls - - optional advanced controls -- Prefer rails/dividers over card stacks. +Recommended scale: -## 5. Compose Architecture Rules +- Display: `34sp / 40sp`, bold +- Section title: `24sp / 30sp`, semibold +- Headline/action: `16sp / 22sp`, semibold +- Body: `15sp / 22sp`, medium +- Callout/helper: `14sp / 20sp`, medium +- Caption 1: `12sp / 16sp`, medium +- Caption 2: `11sp / 14sp`, medium -- State hoisting: - - durable state in `MainViewModel` - - composables receive state + callbacks -- Composable APIs: - - include `modifier: Modifier = Modifier` - - avoid hidden global state -- Side effects: - - use `LaunchedEffect` / activity result APIs - - no blocking work in composition -- Recomposition hygiene: - - `remember`/derived values for computed UI state - - avoid allocating heavy objects on every recomposition +Use monospace only for commands, setup codes, endpoint-like values. +Hard rule: avoid ultra-thin weights on light backgrounds. -## 6. Component Rules +## 5. Layout And Spacing -### Primary / secondary actions +- Respect safe drawing insets. +- Keep content hierarchy mostly via spacing + dividers. +- Prefer vertical rhythm from `8/10/12/14/20dp`. +- Use pinned bottom actions for multi-step or high-importance flows. +- Avoid unnecessary container nesting. -- One dominant primary action per context. -- Secondary actions visibly lower emphasis. -- Avoid duplicate actions that perform the same deterministic step. +## 6. Buttons And Actions -### Inputs + controls +- Primary action: filled accent button, visually dominant. +- Secondary action: lower emphasis (outlined/text/surface button). +- Icon-only buttons must remain legible and >=44dp target. +- Back buttons in action rows use rounded-square shape, not circular by default. -- Clear labels + concise helper text. -- Keep compact fields side-by-side only when both are short and related. -- Advanced settings collapsed by default. +## 7. Inputs And Forms -### WebView and mixed UI +- Always show explicit label or clear context title. +- Keep helper copy short and actionable. +- Validate before advancing steps. +- Prefer immediate inline errors over hidden failure states. +- Keep optional advanced fields explicit (`Manual`, `Advanced`, etc.). -- Keep WebView behavior encapsulated in `AndroidView` wrappers. -- Guard verbose logs and diagnostics behind `BuildConfig.DEBUG`. +## 8. Progress And Multi-Step Flows -## 7. Copy Style +- Use clear step count (`Step X of N`). +- Use labeled progress rail/indicator when steps are discrete. +- Keep navigation predictable: back/next behavior should never surprise. -- Short, operational, direct. -- One helper sentence when possible. -- No repeated status messaging in multiple UI regions. -- Remove filler subtitles that do not change user action. +## 9. Accessibility -## 8. Accessibility + Usability +- Minimum practical touch target: `44dp`. +- Do not rely on color alone for status. +- Preserve high contrast for all text tiers. +- Add meaningful `contentDescription` for icon-only controls. -- Touch targets >= 44dp where practical. -- Do not rely on color alone for state. -- Provide meaningful `contentDescription` for icon-only controls. -- Preserve contrast for status, secondary text, and disabled states. +## 10. Architecture Rules -## 9. Anti-Patterns (Do Not Add) +- Durable UI state in `MainViewModel`. +- Composables: state in, callbacks out. +- No business/network logic in composables. +- Keep side effects explicit (`LaunchedEffect`, activity result APIs). -- Hardcoded theme values sprinkled across screens. -- Business/network logic inside composables. -- Card-inside-card nesting for simple layout. -- Duplicate status pills/header messages for same state. -- Long unbounded helper prose under every control. +## 11. Source Of Truth -## 10. New Screen Checklist +- `app/src/main/java/ai/openclaw/android/ui/OpenClawTheme.kt` +- `app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt` +- `app/src/main/java/ai/openclaw/android/ui/RootScreen.kt` +- `app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt` +- `app/src/main/java/ai/openclaw/android/MainViewModel.kt` -1. Uses shared theme primitives (`MaterialTheme`, existing helpers), no random style constants. -2. Keeps durable state in ViewModel; UI is state + callbacks. -3. Has one clear primary action. -4. Uses spacing/divider-led hierarchy; cards only when needed. -5. Advanced controls collapsed by default. -6. Copy is concise; no duplicate status text. -7. Insets and touch targets are correct. -8. `./gradlew :app:lintDebug` passes. -9. `./gradlew :app:testDebugUnitTest` passes for touched logic. -10. Visual check on API 35 phone emulator and API 31 compatibility emulator. +If style and implementation diverge, update both in the same change. From c015382a77e707ab577e79fcddf5c7d1d053466d Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 18:25:46 +0530 Subject: [PATCH 216/408] feat(android): add connect tab screen with setup and manual modes --- .../openclaw/android/ui/ConnectTabScreen.kt | 590 ++++++++++++++++++ .../ai/openclaw/android/ui/MobileUiTokens.kt | 106 ++++ 2 files changed, 696 insertions(+) create mode 100644 apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt create mode 100644 apps/android/app/src/main/java/ai/openclaw/android/ui/MobileUiTokens.kt diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt new file mode 100644 index 000000000000..da22795cdc22 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt @@ -0,0 +1,590 @@ +package ai.openclaw.android.ui + +import android.util.Base64 +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import ai.openclaw.android.MainViewModel +import java.util.Locale +import org.json.JSONObject + +private enum class ConnectInputMode { + SetupCode, + Manual, +} + +private data class ParsedConnectGateway( + val host: String, + val port: Int, + val tls: Boolean, + val displayUrl: String, +) + +private data class ConnectSetupCodePayload( + val url: String, + val token: String?, + val password: String?, +) + +private data class ConnectConfig( + val host: String, + val port: Int, + val tls: Boolean, + val token: String, + val password: String, +) + +@Composable +fun ConnectTabScreen(viewModel: MainViewModel) { + val statusText by viewModel.statusText.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + val remoteAddress by viewModel.remoteAddress.collectAsState() + val manualHost by viewModel.manualHost.collectAsState() + val manualPort by viewModel.manualPort.collectAsState() + val manualTls by viewModel.manualTls.collectAsState() + val gatewayToken by viewModel.gatewayToken.collectAsState() + val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() + + var advancedOpen by rememberSaveable { mutableStateOf(false) } + var inputMode by rememberSaveable { mutableStateOf(ConnectInputMode.SetupCode) } + var setupCode by rememberSaveable { mutableStateOf("") } + var manualHostInput by rememberSaveable { mutableStateOf(manualHost.ifBlank { "10.0.2.2" }) } + var manualPortInput by rememberSaveable { mutableStateOf(manualPort.toString()) } + var manualTlsInput by rememberSaveable { mutableStateOf(manualTls) } + var tokenInput by rememberSaveable { mutableStateOf(gatewayToken) } + var passwordInput by rememberSaveable { mutableStateOf("") } + var validationText by rememberSaveable { mutableStateOf(null) } + + if (pendingTrust != null) { + val prompt = pendingTrust!! + AlertDialog( + onDismissRequest = { viewModel.declineGatewayTrustPrompt() }, + title = { Text("Trust this gateway?") }, + text = { + Text( + "First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}", + style = mobileCallout, + ) + }, + confirmButton = { + TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) { + Text("Trust and continue") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) { + Text("Cancel") + } + }, + ) + } + + val setupResolvedEndpoint = remember(setupCode) { decodeConnectSetupCode(setupCode)?.url?.let { parseConnectGateway(it)?.displayUrl } } + val manualResolvedEndpoint = remember(manualHostInput, manualPortInput, manualTlsInput) { + composeConnectManualGatewayUrl(manualHostInput, manualPortInput, manualTlsInput)?.let { parseConnectGateway(it)?.displayUrl } + } + + val activeEndpoint = + remember(isConnected, remoteAddress, setupResolvedEndpoint, manualResolvedEndpoint, inputMode) { + when { + isConnected && !remoteAddress.isNullOrBlank() -> remoteAddress!! + inputMode == ConnectInputMode.SetupCode -> setupResolvedEndpoint ?: "Not set" + else -> manualResolvedEndpoint ?: "Not set" + } + } + + val primaryLabel = if (isConnected) "Disconnect Gateway" else "Connect Gateway" + + Column( + modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Connection Control", style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent) + Text("Gateway Connection", style = mobileTitle1, color = mobileText) + Text( + "One primary action. Open advanced controls only when needed.", + style = mobileCallout, + color = mobileTextSecondary, + ) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Active endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText) + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Gateway state", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Text(statusText, style = mobileBody, color = mobileText) + } + } + + Button( + onClick = { + if (isConnected) { + viewModel.disconnect() + validationText = null + return@Button + } + + val config = + resolveConnectConfig( + mode = inputMode, + setupCode = setupCode, + manualHost = manualHostInput, + manualPort = manualPortInput, + manualTls = manualTlsInput, + token = tokenInput, + password = passwordInput, + ) + + if (config == null) { + validationText = + if (inputMode == ConnectInputMode.SetupCode) { + "Paste a valid setup code to connect." + } else { + "Enter a valid manual host and port to connect." + } + return@Button + } + + validationText = null + viewModel.setManualEnabled(true) + viewModel.setManualHost(config.host) + viewModel.setManualPort(config.port) + viewModel.setManualTls(config.tls) + viewModel.setGatewayToken(config.token) + viewModel.setGatewayPassword(config.password) + viewModel.connectManual() + }, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (isConnected) mobileDanger else mobileAccent, + contentColor = Color.White, + ), + ) { + Text(primaryLabel, style = mobileHeadline.copy(fontWeight = FontWeight.Bold)) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + onClick = { advancedOpen = !advancedOpen }, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Advanced controls", style = mobileHeadline, color = mobileText) + Text("Setup code, endpoint, TLS, token, password, onboarding.", style = mobileCaption1, color = mobileTextSecondary) + } + Icon( + imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (advancedOpen) "Collapse advanced controls" else "Expand advanced controls", + tint = mobileTextSecondary, + ) + } + } + + AnimatedVisibility(visible = advancedOpen) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = Color.White, + border = BorderStroke(1.dp, mobileBorder), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Connection method", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MethodChip( + label = "Setup Code", + active = inputMode == ConnectInputMode.SetupCode, + onClick = { inputMode = ConnectInputMode.SetupCode }, + ) + MethodChip( + label = "Manual", + active = inputMode == ConnectInputMode.Manual, + onClick = { inputMode = ConnectInputMode.Manual }, + ) + } + + Text("Run these on the gateway host:", style = mobileCallout, color = mobileTextSecondary) + CommandBlock("openclaw qr --setup-code-only") + CommandBlock("openclaw qr --json") + + if (inputMode == ConnectInputMode.SetupCode) { + Text("Setup Code", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = setupCode, + onValueChange = { + setupCode = it + validationText = null + }, + placeholder = { Text("Paste setup code", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 5, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = mobileBody.copy(fontFamily = FontFamily.Monospace, color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + if (!setupResolvedEndpoint.isNullOrBlank()) { + EndpointPreview(endpoint = setupResolvedEndpoint) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + QuickFillChip( + label = "Android Emulator", + onClick = { + manualHostInput = "10.0.2.2" + manualPortInput = "18789" + manualTlsInput = false + validationText = null + }, + ) + QuickFillChip( + label = "Localhost", + onClick = { + manualHostInput = "127.0.0.1" + manualPortInput = "18789" + manualTlsInput = false + validationText = null + }, + ) + } + + Text("Host", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = manualHostInput, + onValueChange = { + manualHostInput = it + validationText = null + }, + placeholder = { Text("10.0.2.2", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + textStyle = mobileBody.copy(color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + + Text("Port", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = manualPortInput, + onValueChange = { + manualPortInput = it + validationText = null + }, + placeholder = { Text("18789", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = mobileBody.copy(fontFamily = FontFamily.Monospace, color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Use TLS", style = mobileHeadline, color = mobileText) + Text("Switch to secure websocket (`wss`).", style = mobileCallout, color = mobileTextSecondary) + } + Switch( + checked = manualTlsInput, + onCheckedChange = { + manualTlsInput = it + validationText = null + }, + colors = + SwitchDefaults.colors( + checkedTrackColor = mobileAccent, + uncheckedTrackColor = mobileBorderStrong, + checkedThumbColor = Color.White, + uncheckedThumbColor = Color.White, + ), + ) + } + + Text("Token (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = tokenInput, + onValueChange = { tokenInput = it }, + placeholder = { Text("token", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = mobileBody.copy(color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + + Text("Password (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = passwordInput, + onValueChange = { passwordInput = it }, + placeholder = { Text("password", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = mobileBody.copy(color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + + if (!manualResolvedEndpoint.isNullOrBlank()) { + EndpointPreview(endpoint = manualResolvedEndpoint) + } + } + + HorizontalDivider(color = mobileBorder) + + TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) { + Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent) + } + } + } + } + + if (!validationText.isNullOrBlank()) { + Text(validationText!!, style = mobileCaption1, color = mobileWarning) + } + } +} + +@Composable +private fun MethodChip(label: String, active: Boolean, onClick: () -> Unit) { + Button( + onClick = onClick, + modifier = Modifier.height(40.dp), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (active) mobileAccent else mobileSurface, + contentColor = if (active) Color.White else mobileText, + ), + border = BorderStroke(1.dp, if (active) Color(0xFF184DAF) else mobileBorderStrong), + ) { + Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold)) + } +} + +@Composable +private fun QuickFillChip(label: String, onClick: () -> Unit) { + Button( + onClick = onClick, + shape = RoundedCornerShape(999.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = mobileAccentSoft, + contentColor = mobileAccent, + ), + elevation = null, + ) { + Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold)) + } +} + +@Composable +private fun CommandBlock(command: String) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = mobileCodeBg, + border = BorderStroke(1.dp, Color(0xFF2B2E35)), + ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.width(3.dp).height(42.dp).background(Color(0xFF3FC97A))) + Text( + text = command, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = mobileCallout.copy(fontFamily = FontFamily.Monospace), + color = mobileCodeText, + ) + } + } +} + +@Composable +private fun EndpointPreview(endpoint: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + HorizontalDivider(color = mobileBorder) + Text("Resolved endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Text(endpoint, style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = mobileText) + HorizontalDivider(color = mobileBorder) + } +} + +@Composable +private fun outlinedColors() = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = mobileSurface, + unfocusedContainerColor = mobileSurface, + focusedBorderColor = mobileAccent, + unfocusedBorderColor = mobileBorder, + focusedTextColor = mobileText, + unfocusedTextColor = mobileText, + cursorColor = mobileAccent, + ) + +private fun resolveConnectConfig( + mode: ConnectInputMode, + setupCode: String, + manualHost: String, + manualPort: String, + manualTls: Boolean, + token: String, + password: String, +): ConnectConfig? { + return if (mode == ConnectInputMode.SetupCode) { + val setup = decodeConnectSetupCode(setupCode) ?: return null + val parsed = parseConnectGateway(setup.url) ?: return null + ConnectConfig( + host = parsed.host, + port = parsed.port, + tls = parsed.tls, + token = setup.token ?: token.trim(), + password = setup.password ?: password.trim(), + ) + } else { + val manualUrl = composeConnectManualGatewayUrl(manualHost, manualPort, manualTls) ?: return null + val parsed = parseConnectGateway(manualUrl) ?: return null + ConnectConfig( + host = parsed.host, + port = parsed.port, + tls = parsed.tls, + token = token.trim(), + password = password.trim(), + ) + } +} + +private fun parseConnectGateway(rawInput: String): ParsedConnectGateway? { + val raw = rawInput.trim() + if (raw.isEmpty()) return null + + val normalized = if (raw.contains("://")) raw else "https://$raw" + val uri = normalized.toUri() + val host = uri.host?.trim().orEmpty() + if (host.isEmpty()) return null + + val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty() + val tls = + when (scheme) { + "ws", "http" -> false + "wss", "https" -> true + else -> true + } + val port = uri.port.takeIf { it in 1..65535 } ?: 18789 + val displayUrl = "${if (tls) "https" else "http"}://$host:$port" + + return ParsedConnectGateway(host = host, port = port, tls = tls, displayUrl = displayUrl) +} + +private fun decodeConnectSetupCode(rawInput: String): ConnectSetupCodePayload? { + val trimmed = rawInput.trim() + if (trimmed.isEmpty()) return null + + val padded = + trimmed + .replace('-', '+') + .replace('_', '/') + .let { normalized -> + val remainder = normalized.length % 4 + if (remainder == 0) normalized else normalized + "=".repeat(4 - remainder) + } + + return try { + val decoded = String(Base64.decode(padded, Base64.DEFAULT), Charsets.UTF_8) + val obj = JSONObject(decoded) + val url = obj.optString("url").trim() + if (url.isEmpty()) return null + val token = obj.optString("token").trim().ifEmpty { null } + val password = obj.optString("password").trim().ifEmpty { null } + ConnectSetupCodePayload(url = url, token = token, password = password) + } catch (_: Throwable) { + null + } +} + +private fun composeConnectManualGatewayUrl(hostInput: String, portInput: String, tls: Boolean): String? { + val host = hostInput.trim() + val port = portInput.trim().toIntOrNull() ?: return null + if (host.isEmpty() || port !in 1..65535) return null + val scheme = if (tls) "https" else "http" + return "$scheme://$host:$port" +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/MobileUiTokens.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/MobileUiTokens.kt new file mode 100644 index 000000000000..eb4f95775e72 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/MobileUiTokens.kt @@ -0,0 +1,106 @@ +package ai.openclaw.android.ui + +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import ai.openclaw.android.R + +internal val mobileBackgroundGradient = + Brush.verticalGradient( + listOf( + Color(0xFFFFFFFF), + Color(0xFFF7F8FA), + Color(0xFFEFF1F5), + ), + ) + +internal val mobileSurface = Color(0xFFF6F7FA) +internal val mobileSurfaceStrong = Color(0xFFECEEF3) +internal val mobileBorder = Color(0xFFE5E7EC) +internal val mobileBorderStrong = Color(0xFFD6DAE2) +internal val mobileText = Color(0xFF17181C) +internal val mobileTextSecondary = Color(0xFF5D6472) +internal val mobileTextTertiary = Color(0xFF99A0AE) +internal val mobileAccent = Color(0xFF1D5DD8) +internal val mobileAccentSoft = Color(0xFFECF3FF) +internal val mobileSuccess = Color(0xFF2F8C5A) +internal val mobileSuccessSoft = Color(0xFFEEF9F3) +internal val mobileWarning = Color(0xFFC8841A) +internal val mobileWarningSoft = Color(0xFFFFF8EC) +internal val mobileDanger = Color(0xFFD04B4B) +internal val mobileDangerSoft = Color(0xFFFFF2F2) +internal val mobileCodeBg = Color(0xFF15171B) +internal val mobileCodeText = Color(0xFFE8EAEE) + +internal val mobileFontFamily = + FontFamily( + Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal), + Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium), + Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold), + Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold), + ) + +internal val mobileTitle1 = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 30.sp, + letterSpacing = (-0.5).sp, + ) + +internal val mobileTitle2 = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 26.sp, + letterSpacing = (-0.3).sp, + ) + +internal val mobileHeadline = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 22.sp, + letterSpacing = (-0.1).sp, + ) + +internal val mobileBody = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 22.sp, + ) + +internal val mobileCallout = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + ) + +internal val mobileCaption1 = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.2.sp, + ) + +internal val mobileCaption2 = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 14.sp, + letterSpacing = 0.4.sp, + ) From f853622eca6205362a81ea641a2d3ae447deadb3 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 18:25:52 +0530 Subject: [PATCH 217/408] feat(android): switch post-onboarding app to five-tab shell --- .../ai/openclaw/android/ui/CanvasScreen.kt | 129 ++++++ .../openclaw/android/ui/PostOnboardingTabs.kt | 356 +++++++++++++++ .../java/ai/openclaw/android/ui/RootScreen.kt | 418 +----------------- 3 files changed, 486 insertions(+), 417 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt create mode 100644 apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt new file mode 100644 index 000000000000..4faf7791fea7 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt @@ -0,0 +1,129 @@ +package ai.openclaw.android.ui + +import android.annotation.SuppressLint +import android.util.Log +import android.view.View +import android.webkit.ConsoleMessage +import android.webkit.JavascriptInterface +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature +import ai.openclaw.android.MainViewModel + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) { + val context = LocalContext.current + val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 + + AndroidView( + modifier = modifier, + factory = { + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { + WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) + } else { + disableForceDarkIfSupported(settings) + } + if (isDebuggable) { + Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}") + } + isScrollContainer = true + overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS + isVerticalScrollBarEnabled = true + isHorizontalScrollBarEnabled = true + webViewClient = + object : WebViewClient() { + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + if (!isDebuggable || !request.isForMainFrame) return + Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}") + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + if (!isDebuggable || !request.isForMainFrame) return + Log.e( + "OpenClawWebView", + "onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}", + ) + } + + override fun onPageFinished(view: WebView, url: String?) { + if (isDebuggable) { + Log.d("OpenClawWebView", "onPageFinished: $url") + } + viewModel.canvas.onPageFinished() + } + + override fun onRenderProcessGone( + view: WebView, + detail: android.webkit.RenderProcessGoneDetail, + ): Boolean { + if (isDebuggable) { + Log.e( + "OpenClawWebView", + "onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}", + ) + } + return true + } + } + webChromeClient = + object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + if (!isDebuggable) return false + val msg = consoleMessage ?: return false + Log.d( + "OpenClawWebView", + "console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}", + ) + return false + } + } + + val bridge = CanvasA2UIActionBridge { payload -> viewModel.handleCanvasA2UIActionFromWebView(payload) } + addJavascriptInterface(bridge, CanvasA2UIActionBridge.interfaceName) + viewModel.canvas.attach(this) + } + }, + ) +} + +private fun disableForceDarkIfSupported(settings: WebSettings) { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return + @Suppress("DEPRECATION") + WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF) +} + +private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) { + @JavascriptInterface + fun postMessage(payload: String?) { + val msg = payload?.trim().orEmpty() + if (msg.isEmpty()) return + onMessage(msg) + } + + companion object { + const val interfaceName: String = "openclawCanvasA2UIAction" + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt new file mode 100644 index 000000000000..ae153ad9c14f --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt @@ -0,0 +1,356 @@ +package ai.openclaw.android.ui + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ScreenShare +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.RecordVoiceOver +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import ai.openclaw.android.MainViewModel + +private enum class HomeTab( + val label: String, + val icon: ImageVector, +) { + Connect(label = "Connect", icon = Icons.Default.CheckCircle), + Chat(label = "Chat", icon = Icons.Default.ChatBubble), + Voice(label = "Voice", icon = Icons.Default.RecordVoiceOver), + Screen(label = "Screen", icon = Icons.AutoMirrored.Filled.ScreenShare), + Settings(label = "Settings", icon = Icons.Default.Settings), +} + +private enum class StatusVisual { + Connected, + Connecting, + Warning, + Error, + Offline, +} + +@Composable +fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) { + var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) } + + val statusText by viewModel.statusText.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + + val statusVisual = + remember(statusText, isConnected) { + val lower = statusText.lowercase() + when { + isConnected -> StatusVisual.Connected + lower.contains("connecting") || lower.contains("reconnecting") -> StatusVisual.Connecting + lower.contains("pairing") || lower.contains("approval") || lower.contains("auth") -> StatusVisual.Warning + lower.contains("error") || lower.contains("failed") -> StatusVisual.Error + else -> StatusVisual.Offline + } + } + + Scaffold( + modifier = modifier, + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + TopStatusBar( + statusText = statusText, + statusVisual = statusVisual, + ) + }, + bottomBar = { + BottomTabBar( + activeTab = activeTab, + onSelect = { activeTab = it }, + ) + }, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding) + .background(mobileBackgroundGradient), + ) { + when (activeTab) { + HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel) + HomeTab.Chat -> ChatSheet(viewModel = viewModel) + HomeTab.Voice -> VoiceTabScreen(viewModel = viewModel) + HomeTab.Screen -> ScreenTabScreen(viewModel = viewModel) + HomeTab.Settings -> SettingsSheet(viewModel = viewModel) + } + } + } +} + +@Composable +private fun TopStatusBar( + statusText: String, + statusVisual: StatusVisual, +) { + val safeInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + + val (chipBg, chipDot, chipText, chipBorder) = + when (statusVisual) { + StatusVisual.Connected -> + listOf( + mobileSuccessSoft, + mobileSuccess, + mobileSuccess, + Color(0xFFCFEBD8), + ) + StatusVisual.Connecting -> + listOf( + mobileAccentSoft, + mobileAccent, + mobileAccent, + Color(0xFFD5E2FA), + ) + StatusVisual.Warning -> + listOf( + mobileWarningSoft, + mobileWarning, + mobileWarning, + Color(0xFFEED8B8), + ) + StatusVisual.Error -> + listOf( + mobileDangerSoft, + mobileDanger, + mobileDanger, + Color(0xFFF3C8C8), + ) + StatusVisual.Offline -> + listOf( + mobileSurface, + mobileTextTertiary, + mobileTextSecondary, + mobileBorder, + ) + } + + Surface( + modifier = Modifier.fillMaxWidth().windowInsetsPadding(safeInsets), + color = Color.Transparent, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "OpenClaw", + style = mobileTitle2, + color = mobileText, + ) + Surface( + shape = RoundedCornerShape(999.dp), + color = chipBg, + border = androidx.compose.foundation.BorderStroke(1.dp, chipBorder), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.padding(top = 1.dp), + color = chipDot, + shape = RoundedCornerShape(999.dp), + ) { + Box(modifier = Modifier.padding(4.dp)) + } + Text( + text = statusText.trim().ifEmpty { "Offline" }, + style = mobileCaption1, + color = chipText, + maxLines = 1, + ) + } + } + } + } +} + +@Composable +private fun BottomTabBar( + activeTab: HomeTab, + onSelect: (HomeTab) -> Unit, +) { + val safeInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + + Box( + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding(safeInsets), + ) { + Surface( + modifier = Modifier.fillMaxWidth().offset(y = (-4).dp), + color = Color.White.copy(alpha = 0.97f), + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + border = BorderStroke(1.dp, mobileBorder), + shadowElevation = 6.dp, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HomeTab.entries.forEach { tab -> + val active = tab == activeTab + Surface( + onClick = { onSelect(tab) }, + modifier = Modifier.weight(1f).heightIn(min = 58.dp), + shape = RoundedCornerShape(16.dp), + color = if (active) mobileAccentSoft else Color.Transparent, + border = if (active) BorderStroke(1.dp, Color(0xFFD5E2FA)) else null, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 7.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Icon( + imageVector = tab.icon, + contentDescription = tab.label, + tint = if (active) mobileAccent else mobileTextTertiary, + ) + Text( + text = tab.label, + color = if (active) mobileAccent else mobileTextSecondary, + style = mobileCaption2.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.Medium), + ) + } + } + } + } + } + } +} + +@Composable +private fun VoiceTabScreen(viewModel: MainViewModel) { + val context = LocalContext.current + val talkEnabled by viewModel.talkEnabled.collectAsState() + val talkStatusText by viewModel.talkStatusText.collectAsState() + val talkIsListening by viewModel.talkIsListening.collectAsState() + val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState() + val seamColorArgb by viewModel.seamColorArgb.collectAsState() + + val seamColor = remember(seamColorArgb) { Color(seamColorArgb) } + + val audioPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + viewModel.setTalkEnabled(true) + } + } + + Box(modifier = Modifier.fillMaxSize().padding(horizontal = 22.dp, vertical = 18.dp)) { + if (talkEnabled) { + TalkOrbOverlay( + seamColor = seamColor, + statusText = talkStatusText, + isListening = talkIsListening, + isSpeaking = talkIsSpeaking, + modifier = Modifier.align(Alignment.Center), + ) + } else { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("VOICE", style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent) + Text("Talk Mode", style = mobileTitle1, color = mobileText) + Text( + "Enable voice controls and watch live listening/speaking state.", + style = mobileBody, + color = mobileTextSecondary, + ) + } + } + + Button( + onClick = { + if (talkEnabled) { + viewModel.setTalkEnabled(false) + return@Button + } + val micOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + if (micOk) { + viewModel.setTalkEnabled(true) + } else { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + }, + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (talkEnabled) mobileDanger else mobileAccent, + contentColor = Color.White, + ), + ) { + Text( + if (talkEnabled) "Disable Talk Mode" else "Enable Talk Mode", + style = mobileHeadline.copy(fontWeight = FontWeight.Bold), + ) + } + } +} + +@Composable +private fun ScreenTabScreen(viewModel: MainViewModel) { + val cameraFlashToken by viewModel.cameraFlashToken.collectAsState() + + Box(modifier = Modifier.fillMaxSize()) { + CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize()) + CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize()) + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt index 38440ac5a7c9..e50a03cc5bf7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt @@ -1,80 +1,14 @@ package ai.openclaw.android.ui -import android.annotation.SuppressLint -import android.Manifest -import android.content.pm.PackageManager -import android.graphics.Color -import android.util.Log -import android.view.View -import android.webkit.JavascriptInterface -import android.webkit.ConsoleMessage -import android.webkit.WebChromeClient -import android.webkit.WebView -import android.webkit.WebSettings -import android.webkit.WebResourceError -import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse -import android.webkit.WebViewClient -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.webkit.WebSettingsCompat -import androidx.webkit.WebViewFeature -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ScreenShare -import androidx.compose.material.icons.filled.ChatBubble -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.FiberManualRecord -import androidx.compose.material.icons.filled.PhotoCamera -import androidx.compose.material.icons.filled.RecordVoiceOver -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Report -import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color as ComposeColor -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import androidx.core.content.ContextCompat -import ai.openclaw.android.CameraHudKind import ai.openclaw.android.MainViewModel -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RootScreen(viewModel: MainViewModel) { - var sheet by remember { mutableStateOf(null) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) - val context = LocalContext.current val onboardingCompleted by viewModel.onboardingCompleted.collectAsState() if (!onboardingCompleted) { @@ -82,355 +16,5 @@ fun RootScreen(viewModel: MainViewModel) { return } - val serverName by viewModel.serverName.collectAsState() - val statusText by viewModel.statusText.collectAsState() - val cameraHud by viewModel.cameraHud.collectAsState() - val cameraFlashToken by viewModel.cameraFlashToken.collectAsState() - val screenRecordActive by viewModel.screenRecordActive.collectAsState() - val isForeground by viewModel.isForeground.collectAsState() - val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() - val talkEnabled by viewModel.talkEnabled.collectAsState() - val talkStatusText by viewModel.talkStatusText.collectAsState() - val talkIsListening by viewModel.talkIsListening.collectAsState() - val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState() - val seamColorArgb by viewModel.seamColorArgb.collectAsState() - val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) } - val audioPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) viewModel.setTalkEnabled(true) - } - val activity = - remember(cameraHud, screenRecordActive, isForeground, statusText, voiceWakeStatusText) { - // Status pill owns transient activity state so it doesn't overlap the connection indicator. - if (!isForeground) { - return@remember StatusActivity( - title = "Foreground required", - icon = Icons.Default.Report, - contentDescription = "Foreground required", - ) - } - - val lowerStatus = statusText.lowercase() - if (lowerStatus.contains("repair")) { - return@remember StatusActivity( - title = "Repairing…", - icon = Icons.Default.Refresh, - contentDescription = "Repairing", - ) - } - if (lowerStatus.contains("pairing") || lowerStatus.contains("approval")) { - return@remember StatusActivity( - title = "Approval pending", - icon = Icons.Default.RecordVoiceOver, - contentDescription = "Approval pending", - ) - } - // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. - - if (screenRecordActive) { - return@remember StatusActivity( - title = "Recording screen…", - icon = Icons.AutoMirrored.Filled.ScreenShare, - contentDescription = "Recording screen", - tint = androidx.compose.ui.graphics.Color.Red, - ) - } - - cameraHud?.let { hud -> - return@remember when (hud.kind) { - CameraHudKind.Photo -> - StatusActivity( - title = hud.message, - icon = Icons.Default.PhotoCamera, - contentDescription = "Taking photo", - ) - CameraHudKind.Recording -> - StatusActivity( - title = hud.message, - icon = Icons.Default.FiberManualRecord, - contentDescription = "Recording", - tint = androidx.compose.ui.graphics.Color.Red, - ) - CameraHudKind.Success -> - StatusActivity( - title = hud.message, - icon = Icons.Default.CheckCircle, - contentDescription = "Capture finished", - ) - CameraHudKind.Error -> - StatusActivity( - title = hud.message, - icon = Icons.Default.Error, - contentDescription = "Capture failed", - tint = androidx.compose.ui.graphics.Color.Red, - ) - } - } - - if (voiceWakeStatusText.contains("Microphone permission", ignoreCase = true)) { - return@remember StatusActivity( - title = "Mic permission", - icon = Icons.Default.Error, - contentDescription = "Mic permission required", - ) - } - if (voiceWakeStatusText == "Paused") { - val suffix = if (!isForeground) " (background)" else "" - return@remember StatusActivity( - title = "Voice Wake paused$suffix", - icon = Icons.Default.RecordVoiceOver, - contentDescription = "Voice Wake paused", - ) - } - - null - } - - val gatewayState = - remember(serverName, statusText) { - when { - serverName != null -> GatewayState.Connected - statusText.contains("connecting", ignoreCase = true) || - statusText.contains("reconnecting", ignoreCase = true) -> GatewayState.Connecting - statusText.contains("error", ignoreCase = true) -> GatewayState.Error - else -> GatewayState.Disconnected - } - } - - val voiceEnabled = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - - Box(modifier = Modifier.fillMaxSize()) { - CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize()) - } - - // Camera flash must be in a Popup to render above the WebView. - Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { - CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize()) - } - - // Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches. - Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) { - StatusPill( - gateway = gatewayState, - voiceEnabled = voiceEnabled, - activity = activity, - onClick = { sheet = Sheet.Settings }, - modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp), - ) - } - - Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) { - Column( - modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.End, - ) { - OverlayIconButton( - onClick = { sheet = Sheet.Chat }, - icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") }, - ) - - // Talk mode gets a dedicated side bubble instead of burying it in settings. - val baseOverlay = overlayContainerColor() - val talkContainer = - lerp( - baseOverlay, - seamColor.copy(alpha = baseOverlay.alpha), - if (talkEnabled) 0.35f else 0.22f, - ) - val talkContent = if (talkEnabled) seamColor else overlayIconColor() - OverlayIconButton( - onClick = { - val next = !talkEnabled - if (next) { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setTalkEnabled(true) - } else { - viewModel.setTalkEnabled(false) - } - }, - containerColor = talkContainer, - contentColor = talkContent, - icon = { - Icon( - Icons.Default.RecordVoiceOver, - contentDescription = "Talk Mode", - ) - }, - ) - - OverlayIconButton( - onClick = { sheet = Sheet.Settings }, - icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, - ) - } - } - - if (talkEnabled) { - Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { - TalkOrbOverlay( - seamColor = seamColor, - statusText = talkStatusText, - isListening = talkIsListening, - isSpeaking = talkIsSpeaking, - ) - } - } - - val currentSheet = sheet - if (currentSheet != null) { - ModalBottomSheet( - onDismissRequest = { sheet = null }, - sheetState = sheetState, - ) { - when (currentSheet) { - Sheet.Chat -> ChatSheet(viewModel = viewModel) - Sheet.Settings -> SettingsSheet(viewModel = viewModel) - } - } - } -} - -private enum class Sheet { - Chat, - Settings, -} - -@Composable -private fun OverlayIconButton( - onClick: () -> Unit, - icon: @Composable () -> Unit, - containerColor: ComposeColor? = null, - contentColor: ComposeColor? = null, -) { - FilledTonalIconButton( - onClick = onClick, - modifier = Modifier.size(44.dp), - colors = - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = containerColor ?: overlayContainerColor(), - contentColor = contentColor ?: overlayIconColor(), - ), - ) { - icon() - } -} - -@SuppressLint("SetJavaScriptEnabled") -@Composable -private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) { - val context = LocalContext.current - val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 - AndroidView( - modifier = modifier, - factory = { - WebView(context).apply { - settings.javaScriptEnabled = true - // Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage. - settings.domStorageEnabled = true - settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE - if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) - } else { - disableForceDarkIfSupported(settings) - } - if (isDebuggable) { - Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}") - } - isScrollContainer = true - overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS - isVerticalScrollBarEnabled = true - isHorizontalScrollBarEnabled = true - webViewClient = - object : WebViewClient() { - override fun onReceivedError( - view: WebView, - request: WebResourceRequest, - error: WebResourceError, - ) { - if (!isDebuggable) return - if (!request.isForMainFrame) return - Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}") - } - - override fun onReceivedHttpError( - view: WebView, - request: WebResourceRequest, - errorResponse: WebResourceResponse, - ) { - if (!isDebuggable) return - if (!request.isForMainFrame) return - Log.e( - "OpenClawWebView", - "onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}", - ) - } - - override fun onPageFinished(view: WebView, url: String?) { - if (isDebuggable) { - Log.d("OpenClawWebView", "onPageFinished: $url") - } - viewModel.canvas.onPageFinished() - } - - override fun onRenderProcessGone( - view: WebView, - detail: android.webkit.RenderProcessGoneDetail, - ): Boolean { - if (isDebuggable) { - Log.e( - "OpenClawWebView", - "onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}", - ) - } - return true - } - } - webChromeClient = - object : WebChromeClient() { - override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { - if (!isDebuggable) return false - val msg = consoleMessage ?: return false - Log.d( - "OpenClawWebView", - "console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}", - ) - return false - } - } - // Use default layer/background; avoid forcing a black fill over WebView content. - - val a2uiBridge = - CanvasA2UIActionBridge { payload -> - viewModel.handleCanvasA2UIActionFromWebView(payload) - } - addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName) - viewModel.canvas.attach(this) - } - }, - ) -} - -private fun disableForceDarkIfSupported(settings: WebSettings) { - if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return - @Suppress("DEPRECATION") - WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF) -} - -private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) { - @JavascriptInterface - fun postMessage(payload: String?) { - val msg = payload?.trim().orEmpty() - if (msg.isEmpty()) return - onMessage(msg) - } - - companion object { - const val interfaceName: String = "openclawCanvasA2UIAction" - } + PostOnboardingTabs(viewModel = viewModel, modifier = Modifier.fillMaxSize()) } From 4b188dcf975e01fd30cc80790457373aa8d44b66 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 20:55:21 +0530 Subject: [PATCH 218/408] fix(android): persist gateway auth state across onboarding --- .../java/ai/openclaw/android/MainViewModel.kt | 4 +++ .../java/ai/openclaw/android/NodeRuntime.kt | 10 ++++++ .../java/ai/openclaw/android/SecurePrefs.kt | 36 ++++++++++++++++--- .../openclaw/android/ui/ConnectTabScreen.kt | 32 +++++++++++++---- .../ai/openclaw/android/ui/OnboardingFlow.kt | 14 ++++---- 5 files changed, 80 insertions(+), 16 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt index 62f91cf624e7..70aa176922c6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt @@ -139,6 +139,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.setTalkEnabled(enabled) } + fun logGatewayDebugSnapshot(source: String = "manual") { + runtime.logGatewayDebugSnapshot(source) + } + fun refreshGatewayConnection() { runtime.refreshGatewayConnection() } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt index fece62788647..5e6268511aab 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt @@ -4,6 +4,7 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.os.SystemClock +import android.util.Log import androidx.core.content.ContextCompat import ai.openclaw.android.chat.ChatController import ai.openclaw.android.chat.ChatMessage @@ -534,6 +535,15 @@ class NodeRuntime(context: Context) { prefs.setTalkEnabled(value) } + fun logGatewayDebugSnapshot(source: String = "manual") { + val flowToken = gatewayToken.value.trim() + val loadedToken = prefs.loadGatewayToken().orEmpty() + Log.i( + "OpenClawGatewayDebug", + "source=$source manualEnabled=${manualEnabled.value} host=${manualHost.value} port=${manualPort.value} tls=${manualTls.value} flowTokenLen=${flowToken.length} loadTokenLen=${loadedToken.length} connected=${isConnected.value} status=${statusText.value}", + ) + } + fun refreshGatewayConnection() { val endpoint = connectedEndpoint ?: return val token = prefs.loadGatewayToken() diff --git a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt index e0cacd7a3ccc..54f6292d29e2 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt @@ -4,6 +4,7 @@ package ai.openclaw.android import android.content.Context import android.content.SharedPreferences +import android.util.Log import androidx.core.content.edit import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey @@ -13,6 +14,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive +import java.security.MessageDigest import java.util.UUID class SecurePrefs(context: Context) { @@ -98,6 +100,10 @@ class SecurePrefs(context: Context) { private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false)) val talkEnabled: StateFlow = _talkEnabled + init { + logGatewayToken("init.gateway.manual.token", _gatewayToken.value) + } + fun setLastDiscoveredStableId(value: String) { val trimmed = value.trim() prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) } @@ -152,8 +158,10 @@ class SecurePrefs(context: Context) { } fun setGatewayToken(value: String) { - prefs.edit { putString("gateway.manual.token", value) } - _gatewayToken.value = value + val trimmed = value.trim() + prefs.edit(commit = true) { putString("gateway.manual.token", trimmed) } + _gatewayToken.value = trimmed + logGatewayToken("setGatewayToken", trimmed) } fun setGatewayPassword(value: String) { @@ -172,10 +180,15 @@ class SecurePrefs(context: Context) { fun loadGatewayToken(): String? { val manual = _gatewayToken.value.trim() - if (manual.isNotEmpty()) return manual + if (manual.isNotEmpty()) { + logGatewayToken("loadGatewayToken.manual", manual) + return manual + } val key = "gateway.token.${_instanceId.value}" val stored = prefs.getString(key, null)?.trim() - return stored?.takeIf { it.isNotEmpty() } + val resolved = stored?.takeIf { it.isNotEmpty() } + logGatewayToken("loadGatewayToken.legacy", resolved.orEmpty()) + return resolved } fun saveGatewayToken(token: String) { @@ -234,6 +247,21 @@ class SecurePrefs(context: Context) { return fresh } + private fun logGatewayToken(event: String, value: String) { + val digest = + if (value.isBlank()) { + "empty" + } else { + try { + val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) + bytes.take(4).joinToString("") { "%02x".format(it) } + } catch (_: Throwable) { + "hash_err" + } + } + Log.i("OpenClawSecurePrefs", "$event tokenLen=${value.length} tokenSha256Prefix=$digest") + } + private fun loadOrMigrateDisplayName(context: Context): String { val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty() if (existing.isNotEmpty() && existing != "Android Node") return existing diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt index da22795cdc22..24336849d507 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt @@ -86,16 +86,25 @@ fun ConnectTabScreen(viewModel: MainViewModel) { val manualHost by viewModel.manualHost.collectAsState() val manualPort by viewModel.manualPort.collectAsState() val manualTls by viewModel.manualTls.collectAsState() + val manualEnabled by viewModel.manualEnabled.collectAsState() val gatewayToken by viewModel.gatewayToken.collectAsState() val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() var advancedOpen by rememberSaveable { mutableStateOf(false) } - var inputMode by rememberSaveable { mutableStateOf(ConnectInputMode.SetupCode) } + var inputMode by + remember(manualEnabled, manualHost, gatewayToken) { + mutableStateOf( + if (manualEnabled || manualHost.isNotBlank() || gatewayToken.trim().isNotEmpty()) { + ConnectInputMode.Manual + } else { + ConnectInputMode.SetupCode + }, + ) + } var setupCode by rememberSaveable { mutableStateOf("") } var manualHostInput by rememberSaveable { mutableStateOf(manualHost.ifBlank { "10.0.2.2" }) } var manualPortInput by rememberSaveable { mutableStateOf(manualPort.toString()) } var manualTlsInput by rememberSaveable { mutableStateOf(manualTls) } - var tokenInput by rememberSaveable { mutableStateOf(gatewayToken) } var passwordInput by rememberSaveable { mutableStateOf("") } var validationText by rememberSaveable { mutableStateOf(null) } @@ -192,7 +201,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) { manualHost = manualHostInput, manualPort = manualPortInput, manualTls = manualTlsInput, - token = tokenInput, + token = gatewayToken, password = passwordInput, ) @@ -211,7 +220,9 @@ fun ConnectTabScreen(viewModel: MainViewModel) { viewModel.setManualHost(config.host) viewModel.setManualPort(config.port) viewModel.setManualTls(config.tls) - viewModel.setGatewayToken(config.token) + if (config.token.isNotBlank()) { + viewModel.setGatewayToken(config.token) + } viewModel.setGatewayPassword(config.password) viewModel.connectManual() }, @@ -380,8 +391,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) { Text("Token (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) OutlinedTextField( - value = tokenInput, - onValueChange = { tokenInput = it }, + value = gatewayToken, + onValueChange = { viewModel.setGatewayToken(it) }, placeholder = { Text("token", style = mobileBody, color = mobileTextTertiary) }, modifier = Modifier.fillMaxWidth(), singleLine = true, @@ -411,6 +422,15 @@ fun ConnectTabScreen(viewModel: MainViewModel) { HorizontalDivider(color = mobileBorder) + Text( + "Debug snapshot: mode=${if (inputMode == ConnectInputMode.SetupCode) "setup" else "manual"}, manualEnabled=$manualEnabled, tokenLen=${gatewayToken.trim().length}", + style = mobileCaption1, + color = mobileTextSecondary, + ) + TextButton(onClick = { viewModel.logGatewayDebugSnapshot(source = "connect_tab") }) { + Text("Log gateway debug snapshot", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent) + } + TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) { Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent) } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt index d35cab59f549..780781455be9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt @@ -201,12 +201,12 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { val isConnected by viewModel.isConnected.collectAsState() val serverName by viewModel.serverName.collectAsState() val remoteAddress by viewModel.remoteAddress.collectAsState() + val persistedGatewayToken by viewModel.gatewayToken.collectAsState() val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() var step by rememberSaveable { mutableStateOf(OnboardingStep.Welcome) } var setupCode by rememberSaveable { mutableStateOf("") } var gatewayUrl by rememberSaveable { mutableStateOf("") } - var gatewayToken by rememberSaveable { mutableStateOf("") } var gatewayPassword by rememberSaveable { mutableStateOf("") } var gatewayInputMode by rememberSaveable { mutableStateOf(GatewayInputMode.SetupCode) } var manualHost by rememberSaveable { mutableStateOf("10.0.2.2") } @@ -337,7 +337,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { manualHost = manualHost, manualPort = manualPort, manualTls = manualTls, - gatewayToken = gatewayToken, + gatewayToken = persistedGatewayToken, gatewayPassword = gatewayPassword, gatewayError = gatewayError, onInputModeChange = { @@ -357,7 +357,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { gatewayError = null }, onManualTlsChange = { manualTls = it }, - onTokenChange = { gatewayToken = it }, + onTokenChange = viewModel::setGatewayToken, onPasswordChange = { gatewayPassword = it }, ) OnboardingStep.Permissions -> @@ -455,7 +455,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { return@Button } gatewayUrl = parsedSetup.url - gatewayToken = parsedSetup.token.orEmpty() + parsedSetup.token?.let { viewModel.setGatewayToken(it) } gatewayPassword = parsedSetup.password.orEmpty() } else { val manualUrl = composeManualGatewayUrl(manualHost, manualPort, manualTls) @@ -530,14 +530,16 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { gatewayError = "Invalid gateway URL." return@Button } - val token = gatewayToken.trim() + val token = persistedGatewayToken.trim() val password = gatewayPassword.trim() attemptedConnect = true viewModel.setManualEnabled(true) viewModel.setManualHost(parsed.host) viewModel.setManualPort(parsed.port) viewModel.setManualTls(parsed.tls) - viewModel.setGatewayToken(token) + if (token.isNotEmpty()) { + viewModel.setGatewayToken(token) + } viewModel.setGatewayPassword(password) viewModel.connectManual() }, From 14f5217e228ad9f7c2870d65d3ac3e3bf6e66173 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 20:55:25 +0530 Subject: [PATCH 219/408] fix(android): retry with shared token after device-token failure --- .../android/gateway/GatewaySession.kt | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt index 0f49541daff7..5550ec00664d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt @@ -301,16 +301,29 @@ class GatewaySession( val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) val trimmedToken = token?.trim().orEmpty() val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken - val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank() val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim()) - val res = request("connect", payload, timeoutMs = 8_000) + var res = request("connect", payload, timeoutMs = 8_000) if (!res.ok) { val msg = res.error?.message ?: "connect failed" - if (canFallbackToShared) { + val hasStoredToken = !storedToken.isNullOrBlank() + val canRetryWithShared = hasStoredToken && trimmedToken.isNotBlank() + if (hasStoredToken) { deviceAuthStore.clearToken(identity.deviceId, options.role) } - throw IllegalStateException(msg) + if (canRetryWithShared) { + val sharedPayload = buildConnectParams(identity, connectNonce, trimmedToken, password?.trim()) + res = request("connect", sharedPayload, timeoutMs = 8_000) + } + if (!res.ok) { + val retryMsg = res.error?.message ?: msg + throw IllegalStateException(retryMsg) + } } + handleConnectSuccess(res, identity.deviceId) + connectDeferred.complete(Unit) + } + + private fun handleConnectSuccess(res: RpcResponse, deviceId: String) { val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload") val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed") val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull() @@ -318,7 +331,7 @@ class GatewaySession( val deviceToken = authObj?.get("deviceToken").asStringOrNull() val authRole = authObj?.get("role").asStringOrNull() ?: options.role if (!deviceToken.isNullOrBlank()) { - deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken) + deviceAuthStore.saveToken(deviceId, authRole, deviceToken) } val rawCanvas = obj["canvasHostUrl"].asStringOrNull() canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint) @@ -327,7 +340,6 @@ class GatewaySession( ?.get("sessionDefaults").asObjectOrNull() mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull() onConnected(serverName, remoteAddress, mainSessionKey) - connectDeferred.complete(Unit) } private fun buildConnectParams( From 439d8e609e400a9b96bc5ba9be612fb73ae9efc2 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 20:55:30 +0530 Subject: [PATCH 220/408] fix(android): use native client id for operator session --- .../src/main/java/ai/openclaw/android/node/ConnectionManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt index d15d928e0a45..9b449fc85f37 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt @@ -176,7 +176,7 @@ class ConnectionManager( caps = emptyList(), commands = emptyList(), permissions = emptyMap(), - client = buildClientInfo(clientId = "openclaw-control-ui", clientMode = "ui"), + client = buildClientInfo(clientId = "openclaw-android", clientMode = "ui"), userAgent = buildUserAgent(), ) } From cf031d6ad4040e8f297b3add171d61b70c38869d Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 21:02:15 +0530 Subject: [PATCH 221/408] chore(android): remove unused legacy ui components --- .../java/ai/openclaw/android/ui/StatusPill.kt | 114 ------------------ .../android/ui/chat/ChatSessionsDialog.kt | 92 -------------- 2 files changed, 206 deletions(-) delete mode 100644 apps/android/app/src/main/java/ai/openclaw/android/ui/StatusPill.kt delete mode 100644 apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSessionsDialog.kt diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/StatusPill.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/StatusPill.kt deleted file mode 100644 index d608fc38a7bb..000000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/StatusPill.kt +++ /dev/null @@ -1,114 +0,0 @@ -package ai.openclaw.android.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.MicOff -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp - -@Composable -fun StatusPill( - gateway: GatewayState, - voiceEnabled: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - activity: StatusActivity? = null, -) { - Surface( - onClick = onClick, - modifier = modifier, - shape = RoundedCornerShape(14.dp), - color = overlayContainerColor(), - tonalElevation = 3.dp, - shadowElevation = 0.dp, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Surface( - modifier = Modifier.size(9.dp), - shape = CircleShape, - color = gateway.color, - ) {} - - Text( - text = gateway.title, - style = MaterialTheme.typography.labelLarge, - ) - } - - VerticalDivider( - modifier = Modifier.height(14.dp).alpha(0.35f), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - if (activity != null) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = activity.icon, - contentDescription = activity.contentDescription, - tint = activity.tint ?: overlayIconColor(), - modifier = Modifier.size(18.dp), - ) - Text( - text = activity.title, - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - ) - } - } else { - Icon( - imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff, - contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled", - tint = - if (voiceEnabled) { - overlayIconColor() - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.size(18.dp), - ) - } - - Spacer(modifier = Modifier.width(2.dp)) - } - } -} - -data class StatusActivity( - val title: String, - val icon: androidx.compose.ui.graphics.vector.ImageVector, - val contentDescription: String, - val tint: Color? = null, -) - -enum class GatewayState(val title: String, val color: Color) { - Connected("Connected", Color(0xFF2ECC71)), - Connecting("Connecting…", Color(0xFFF1C40F)), - Error("Error", Color(0xFFE74C3C)), - Disconnected("Offline", Color(0xFF9E9E9E)), -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSessionsDialog.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSessionsDialog.kt deleted file mode 100644 index 56b5cfb1faf6..000000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSessionsDialog.kt +++ /dev/null @@ -1,92 +0,0 @@ -package ai.openclaw.android.ui.chat - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import ai.openclaw.android.chat.ChatSessionEntry - -@Composable -fun ChatSessionsDialog( - currentSessionKey: String, - sessions: List, - onDismiss: () -> Unit, - onRefresh: () -> Unit, - onSelect: (sessionKey: String) -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - confirmButton = {}, - title = { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text("Sessions", style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.weight(1f)) - FilledTonalIconButton(onClick = onRefresh) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh") - } - } - }, - text = { - if (sessions.isEmpty()) { - Text("No sessions", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } else { - LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(sessions, key = { it.key }) { entry -> - SessionRow( - entry = entry, - isCurrent = entry.key == currentSessionKey, - onClick = { onSelect(entry.key) }, - ) - } - } - } - }, - ) -} - -@Composable -private fun SessionRow( - entry: ChatSessionEntry, - isCurrent: Boolean, - onClick: () -> Unit, -) { - Surface( - onClick = onClick, - shape = MaterialTheme.shapes.medium, - color = - if (isCurrent) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) - } else { - MaterialTheme.colorScheme.surfaceContainer - }, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text(entry.displayName ?: entry.key, style = MaterialTheme.typography.bodyMedium) - Spacer(modifier = Modifier.weight(1f)) - if (isCurrent) { - Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} From 02e3fbef77a68c1cb91069caa35cdc3e121905ab Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 21:19:39 +0530 Subject: [PATCH 222/408] style(android): align settings screen with RN visual system --- .../ai/openclaw/android/ui/SettingsSheet.kt | 437 +++++++++++++----- 1 file changed, 316 insertions(+), 121 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt index ad5a891e17b5..d04dd5cab958 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt @@ -10,9 +10,13 @@ import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -23,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn @@ -30,16 +35,20 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.AlertDialog import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -56,8 +65,12 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import ai.openclaw.android.BuildConfig @@ -114,6 +127,14 @@ fun SettingsSheet(viewModel: MainViewModel) { versionName } } + val listItemColors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = mobileText, + supportingColor = mobileTextSecondary, + trailingIconColor = mobileTextSecondary, + leadingIconColor = mobileTextSecondary, + ) if (pendingTrust != null) { val prompt = pendingTrust!! @@ -284,41 +305,78 @@ fun SettingsSheet(viewModel: MainViewModel) { "Discovery active • ${visibleGateways.size} gateway${if (visibleGateways.size == 1) "" else "s"} found" } - LazyColumn( - state = listState, + Box( modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .imePadding() - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + .fillMaxSize() + .background(mobileBackgroundGradient), ) { + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight() + .imePadding() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + "SETTINGS", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + Text("Device + Gateway Configuration", style = mobileTitle2, color = mobileText) + Text( + "Manage capabilities, connection mode, permissions, and diagnostics.", + style = mobileCallout, + color = mobileTextSecondary, + ) + } + } + item { HorizontalDivider(color = mobileBorder) } + // Order parity: Node → Gateway → Voice → Camera → Messaging → Location → Screen. - item { Text("Node", style = MaterialTheme.typography.titleSmall) } + item { + Text( + "NODE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { OutlinedTextField( value = displayName, onValueChange = viewModel::setDisplayName, - label = { Text("Name") }, + label = { Text("Name", style = mobileCaption1, color = mobileTextSecondary) }, modifier = Modifier.fillMaxWidth(), + textStyle = mobileBody.copy(color = mobileText), + colors = settingsTextFieldColors(), ) } - item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) } - item { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) } - item { Text("Version: $appVersion", color = MaterialTheme.colorScheme.onSurfaceVariant) } + item { Text("Instance ID: $instanceId", style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = mobileTextSecondary) } + item { Text("Device: $deviceModel", style = mobileCallout, color = mobileTextSecondary) } + item { Text("Version: $appVersion", style = mobileCallout, color = mobileTextSecondary) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Gateway - item { Text("Gateway", style = MaterialTheme.typography.titleSmall) } - item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) } + item { + Text( + "GATEWAY", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } + item { ListItem(modifier = settingsRowModifier(), colors = listItemColors, headlineContent = { Text("Status", style = mobileHeadline) }, supportingContent = { Text(statusText, style = mobileCallout) }) } if (serverName != null) { - item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) } + item { ListItem(modifier = settingsRowModifier(), colors = listItemColors, headlineContent = { Text("Server", style = mobileHeadline) }, supportingContent = { Text(serverName!!, style = mobileCallout) }) } } if (remoteAddress != null) { - item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) } + item { ListItem(modifier = settingsRowModifier(), colors = listItemColors, headlineContent = { Text("Address", style = mobileHeadline) }, supportingContent = { Text(remoteAddress!!, style = mobileCallout.copy(fontFamily = FontFamily.Monospace)) }) } } item { // UI sanity: "Disconnect" only when we have an active remote. @@ -328,23 +386,26 @@ fun SettingsSheet(viewModel: MainViewModel) { viewModel.disconnect() NodeForegroundService.stop(context) }, + colors = settingsDangerButtonColors(), + shape = RoundedCornerShape(14.dp), ) { - Text("Disconnect") + Text("Disconnect", style = mobileHeadline.copy(fontWeight = FontWeight.Bold)) } } } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } if (!isConnected || visibleGateways.isNotEmpty()) { item { Text( if (isConnected) "Other Gateways" else "Discovered Gateways", - style = MaterialTheme.typography.titleSmall, + style = mobileHeadline, + color = mobileText, ) } if (!isConnected && visibleGateways.isEmpty()) { - item { Text("No gateways found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) } + item { Text("No gateways found yet.", style = mobileCallout, color = mobileTextSecondary) } } else { items(items = visibleGateways, key = { it.stableId }) { gateway -> val detailLines = @@ -359,11 +420,13 @@ fun SettingsSheet(viewModel: MainViewModel) { } } ListItem( - headlineContent = { Text(gateway.name) }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text(gateway.name, style = mobileHeadline) }, supportingContent = { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { detailLines.forEach { line -> - Text(line, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(line, style = mobileCallout, color = mobileTextSecondary) } } }, @@ -373,8 +436,10 @@ fun SettingsSheet(viewModel: MainViewModel) { NodeForegroundService.start(context) viewModel.connect(gateway) }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), ) { - Text("Connect") + Text("Connect", style = mobileCallout.copy(fontWeight = FontWeight.Bold)) } }, ) @@ -385,66 +450,82 @@ fun SettingsSheet(viewModel: MainViewModel) { gatewayDiscoveryFooterText, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = mobileCaption1, + color = mobileTextSecondary, ) } } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } item { ListItem( - headlineContent = { Text("Advanced") }, - supportingContent = { Text("Manual gateway connection") }, + modifier = settingsRowModifier().then(Modifier.clickable { setAdvancedExpanded(!advancedExpanded) }), + colors = listItemColors, + headlineContent = { Text("Advanced", style = mobileHeadline) }, + supportingContent = { Text("Manual gateway connection", style = mobileCallout) }, trailingContent = { Icon( imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, contentDescription = if (advancedExpanded) "Collapse" else "Expand", + tint = mobileTextSecondary, ) }, - modifier = - Modifier.clickable { - setAdvancedExpanded(!advancedExpanded) - }, ) } item { AnimatedVisibility(visible = advancedExpanded) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = + Modifier + .fillMaxWidth() + .border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp)) + .background(mobileSurface, RoundedCornerShape(14.dp)) + .padding(12.dp), + ) { ListItem( - headlineContent = { Text("Use Manual Gateway") }, - supportingContent = { Text("Use this when discovery is blocked.") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Use Manual Gateway", style = mobileHeadline) }, + supportingContent = { Text("Use this when discovery is blocked.", style = mobileCallout) }, trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) }, ) OutlinedTextField( value = manualHost, onValueChange = viewModel::setManualHost, - label = { Text("Host") }, + label = { Text("Host", style = mobileCaption1, color = mobileTextSecondary) }, modifier = Modifier.fillMaxWidth(), enabled = manualEnabled, + textStyle = mobileBody.copy(color = mobileText), + colors = settingsTextFieldColors(), ) OutlinedTextField( value = manualPort.toString(), onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) }, - label = { Text("Port") }, + label = { Text("Port", style = mobileCaption1, color = mobileTextSecondary) }, modifier = Modifier.fillMaxWidth(), enabled = manualEnabled, + textStyle = mobileBody.copy(fontFamily = FontFamily.Monospace, color = mobileText), + colors = settingsTextFieldColors(), ) OutlinedTextField( value = gatewayToken, onValueChange = viewModel::setGatewayToken, - label = { Text("Gateway Token") }, + label = { Text("Gateway Token", style = mobileCaption1, color = mobileTextSecondary) }, modifier = Modifier.fillMaxWidth(), enabled = manualEnabled, singleLine = true, + textStyle = mobileBody.copy(color = mobileText), + colors = settingsTextFieldColors(), ) ListItem( - headlineContent = { Text("Require TLS") }, - supportingContent = { Text("Pin the gateway certificate on first connect.") }, + modifier = settingsRowModifier().alpha(if (manualEnabled) 1f else 0.5f), + colors = listItemColors, + headlineContent = { Text("Require TLS", style = mobileHeadline) }, + supportingContent = { Text("Pin the gateway certificate on first connect.", style = mobileCallout) }, trailingContent = { Switch(checked = manualTls, onCheckedChange = viewModel::setManualTls, enabled = manualEnabled) }, - modifier = Modifier.alpha(if (manualEnabled) 1f else 0.5f), ) val hostOk = manualHost.trim().isNotEmpty() @@ -455,26 +536,36 @@ fun SettingsSheet(viewModel: MainViewModel) { viewModel.connectManual() }, enabled = manualEnabled && hostOk && portOk, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), ) { - Text("Connect (Manual)") + Text("Connect (Manual)", style = mobileCallout.copy(fontWeight = FontWeight.Bold)) } TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) { - Text("Run onboarding again") + Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent) } } } } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Voice - item { Text("Voice", style = MaterialTheme.typography.titleSmall) } + item { + Text( + "VOICE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { val enabled = voiceWakeMode != VoiceWakeMode.Off ListItem( - headlineContent = { Text("Voice Wake") }, - supportingContent = { Text(voiceWakeStatusText) }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Voice Wake", style = mobileHeadline) }, + supportingContent = { Text(voiceWakeStatusText, style = mobileCallout) }, trailingContent = { Switch( checked = enabled, @@ -497,8 +588,10 @@ fun SettingsSheet(viewModel: MainViewModel) { AnimatedVisibility(visible = voiceWakeMode != VoiceWakeMode.Off) { Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { ListItem( - headlineContent = { Text("Foreground Only") }, - supportingContent = { Text("Listens only while OpenClaw is open.") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Foreground Only", style = mobileHeadline) }, + supportingContent = { Text("Listens only while OpenClaw is open.", style = mobileCallout) }, trailingContent = { RadioButton( selected = voiceWakeMode == VoiceWakeMode.Foreground, @@ -513,8 +606,10 @@ fun SettingsSheet(viewModel: MainViewModel) { }, ) ListItem( - headlineContent = { Text("Always") }, - supportingContent = { Text("Keeps listening in the background (shows a persistent notification).") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Always", style = mobileHeadline) }, + supportingContent = { Text("Keeps listening in the background (shows a persistent notification).", style = mobileCallout) }, trailingContent = { RadioButton( selected = voiceWakeMode == VoiceWakeMode.Always, @@ -535,7 +630,7 @@ fun SettingsSheet(viewModel: MainViewModel) { OutlinedTextField( value = wakeWordsText, onValueChange = setWakeWordsText, - label = { Text("Wake Words (comma-separated)") }, + label = { Text("Wake Words (comma-separated)", style = mobileCaption1, color = mobileTextSecondary) }, modifier = Modifier.fillMaxWidth().onFocusChanged { focusState -> if (focusState.isFocused) { @@ -554,9 +649,19 @@ fun SettingsSheet(viewModel: MainViewModel) { focusManager.clearFocus() }, ), + textStyle = mobileBody.copy(color = mobileText), + colors = settingsTextFieldColors(), ) } - item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } } + item { + Button( + onClick = viewModel::resetWakeWordsDefaults, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text("Reset defaults", style = mobileCallout.copy(fontWeight = FontWeight.Bold)) + } + } item { Text( if (isConnected) { @@ -564,32 +669,48 @@ fun SettingsSheet(viewModel: MainViewModel) { } else { "Connect to a gateway to sync wake words globally." }, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = mobileCallout, + color = mobileTextSecondary, ) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Camera - item { Text("Camera", style = MaterialTheme.typography.titleSmall) } + item { + Text( + "CAMERA", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { ListItem( - headlineContent = { Text("Allow Camera") }, - supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Allow Camera", style = mobileHeadline) }, + supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).", style = mobileCallout) }, trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) }, ) } item { Text( "Tip: grant Microphone permission for video clips with audio.", - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = mobileCallout, + color = mobileTextSecondary, ) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Messaging - item { Text("Messaging", style = MaterialTheme.typography.titleSmall) } + item { + Text( + "MESSAGING", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { val buttonLabel = when { @@ -598,7 +719,9 @@ fun SettingsSheet(viewModel: MainViewModel) { else -> "Grant" } ListItem( - headlineContent = { Text("SMS Permission") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("SMS Permission", style = mobileHeadline) }, supportingContent = { Text( if (smsPermissionAvailable) { @@ -606,6 +729,7 @@ fun SettingsSheet(viewModel: MainViewModel) { } else { "SMS requires a device with telephony hardware." }, + style = mobileCallout, ) }, trailingContent = { @@ -619,91 +743,125 @@ fun SettingsSheet(viewModel: MainViewModel) { } }, enabled = smsPermissionAvailable, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), ) { - Text(buttonLabel) + Text(buttonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold)) } }, ) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Location - item { Text("Location", style = MaterialTheme.typography.titleSmall) } - item { - Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { - ListItem( - headlineContent = { Text("Off") }, - supportingContent = { Text("Disable location sharing.") }, - trailingContent = { - RadioButton( - selected = locationMode == LocationMode.Off, - onClick = { viewModel.setLocationMode(LocationMode.Off) }, - ) - }, - ) - ListItem( - headlineContent = { Text("While Using") }, - supportingContent = { Text("Only while OpenClaw is open.") }, - trailingContent = { - RadioButton( - selected = locationMode == LocationMode.WhileUsing, - onClick = { requestLocationPermissions(LocationMode.WhileUsing) }, - ) - }, - ) - ListItem( - headlineContent = { Text("Always") }, - supportingContent = { Text("Allow background location (requires system permission).") }, - trailingContent = { - RadioButton( - selected = locationMode == LocationMode.Always, - onClick = { requestLocationPermissions(LocationMode.Always) }, - ) - }, + item { + Text( + "LOCATION", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, ) } - } - item { - ListItem( - headlineContent = { Text("Precise Location") }, - supportingContent = { Text("Use precise GPS when available.") }, - trailingContent = { - Switch( - checked = locationPreciseEnabled, - onCheckedChange = ::setPreciseLocationChecked, - enabled = locationMode != LocationMode.Off, + item { + Column(modifier = settingsRowModifier(), verticalArrangement = Arrangement.spacedBy(0.dp)) { + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Off", style = mobileHeadline) }, + supportingContent = { Text("Disable location sharing.", style = mobileCallout) }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.Off, + onClick = { viewModel.setLocationMode(LocationMode.Off) }, + ) + }, ) - }, - ) - } + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("While Using", style = mobileHeadline) }, + supportingContent = { Text("Only while OpenClaw is open.", style = mobileCallout) }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.WhileUsing, + onClick = { requestLocationPermissions(LocationMode.WhileUsing) }, + ) + }, + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Always", style = mobileHeadline) }, + supportingContent = { Text("Allow background location (requires system permission).", style = mobileCallout) }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.Always, + onClick = { requestLocationPermissions(LocationMode.Always) }, + ) + }, + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Precise Location", style = mobileHeadline) }, + supportingContent = { Text("Use precise GPS when available.", style = mobileCallout) }, + trailingContent = { + Switch( + checked = locationPreciseEnabled, + onCheckedChange = ::setPreciseLocationChecked, + enabled = locationMode != LocationMode.Off, + ) + }, + ) + } + } item { Text( "Always may require Android Settings to allow background location.", - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = mobileCallout, + color = mobileTextSecondary, ) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Screen - item { Text("Screen", style = MaterialTheme.typography.titleSmall) } + item { + Text( + "SCREEN", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { ListItem( - headlineContent = { Text("Prevent Sleep") }, - supportingContent = { Text("Keeps the screen awake while OpenClaw is open.") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Prevent Sleep", style = mobileHeadline) }, + supportingContent = { Text("Keeps the screen awake while OpenClaw is open.", style = mobileCallout) }, trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) }, ) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Debug - item { Text("Debug", style = MaterialTheme.typography.titleSmall) } + item { + Text( + "DEBUG", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { ListItem( - headlineContent = { Text("Debug Canvas Status") }, - supportingContent = { Text("Show status text in the canvas when debug is enabled.") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Debug Canvas Status", style = mobileHeadline) }, + supportingContent = { Text("Show status text in the canvas when debug is enabled.", style = mobileCallout) }, trailingContent = { Switch( checked = canvasDebugStatusEnabled, @@ -713,10 +871,47 @@ fun SettingsSheet(viewModel: MainViewModel) { ) } - item { Spacer(modifier = Modifier.height(20.dp)) } + item { Spacer(modifier = Modifier.height(24.dp)) } + } } } +@Composable +private fun settingsTextFieldColors() = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = mobileSurface, + unfocusedContainerColor = mobileSurface, + focusedBorderColor = mobileAccent, + unfocusedBorderColor = mobileBorder, + focusedTextColor = mobileText, + unfocusedTextColor = mobileText, + cursorColor = mobileAccent, + ) + +private fun settingsRowModifier() = + Modifier + .fillMaxWidth() + .border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp)) + .background(Color.White, RoundedCornerShape(14.dp)) + +@Composable +private fun settingsPrimaryButtonColors() = + ButtonDefaults.buttonColors( + containerColor = mobileAccent, + contentColor = Color.White, + disabledContainerColor = mobileAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White.copy(alpha = 0.9f), + ) + +@Composable +private fun settingsDangerButtonColors() = + ButtonDefaults.buttonColors( + containerColor = mobileDanger, + contentColor = Color.White, + disabledContainerColor = mobileDanger.copy(alpha = 0.45f), + disabledContentColor = Color.White.copy(alpha = 0.9f), + ) + private fun openAppSettings(context: Context) { val intent = Intent( From b658000bf7a9e5047364b9817d24988f6345232c Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 21:34:08 +0530 Subject: [PATCH 223/408] style(android-chat): refine thread shell and empty states --- .../android/ui/chat/ChatMessageListCard.kt | 120 +++++++++--------- .../android/ui/chat/ChatSheetContent.kt | 40 +++++- 2 files changed, 96 insertions(+), 64 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt index bcec19a5fa25..889de006cb45 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt @@ -2,26 +2,26 @@ package ai.openclaw.android.ui.chat import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowCircleDown -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp import ai.openclaw.android.chat.ChatMessage import ai.openclaw.android.chat.ChatPendingToolCall +import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileHeadline +import ai.openclaw.android.ui.mobileText +import ai.openclaw.android.ui.mobileTextSecondary @Composable fun ChatMessageListCard( @@ -29,6 +29,7 @@ fun ChatMessageListCard( pendingRunCount: Int, pendingToolCalls: List, streamingAssistantText: String?, + healthOk: Boolean, modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() @@ -38,73 +39,70 @@ fun ChatMessageListCard( listState.animateScrollToItem(index = 0) } - Card( - modifier = modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Box(modifier = Modifier.fillMaxSize()) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - reverseLayout = true, - verticalArrangement = Arrangement.spacedBy(14.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 12.dp), - ) { - // With reverseLayout = true, index 0 renders at the BOTTOM. - // So we emit newest items first: streaming → tools → typing → messages (newest→oldest). + Box(modifier = modifier.fillMaxWidth()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + reverseLayout = true, + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 8.dp), + ) { + // With reverseLayout = true, index 0 renders at the BOTTOM. + // So we emit newest items first: streaming → tools → typing → messages (newest→oldest). - val stream = streamingAssistantText?.trim() - if (!stream.isNullOrEmpty()) { - item(key = "stream") { - ChatStreamingAssistantBubble(text = stream) - } - } - - if (pendingToolCalls.isNotEmpty()) { - item(key = "tools") { - ChatPendingToolsBubble(toolCalls = pendingToolCalls) - } + val stream = streamingAssistantText?.trim() + if (!stream.isNullOrEmpty()) { + item(key = "stream") { + ChatStreamingAssistantBubble(text = stream) } + } - if (pendingRunCount > 0) { - item(key = "typing") { - ChatTypingIndicatorBubble() - } + if (pendingToolCalls.isNotEmpty()) { + item(key = "tools") { + ChatPendingToolsBubble(toolCalls = pendingToolCalls) } + } - items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx -> - ChatMessageBubble(message = messages[messages.size - 1 - idx]) + if (pendingRunCount > 0) { + item(key = "typing") { + ChatTypingIndicatorBubble() } } - if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) { - EmptyChatHint(modifier = Modifier.align(Alignment.Center)) + items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx -> + ChatMessageBubble(message = messages[messages.size - 1 - idx]) } } + + if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) { + EmptyChatHint(modifier = Modifier.align(Alignment.Center), healthOk = healthOk) + } } } @Composable -private fun EmptyChatHint(modifier: Modifier = Modifier) { - Row( - modifier = modifier.alpha(0.7f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), +private fun EmptyChatHint(modifier: Modifier = Modifier, healthOk: Boolean) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f), + border = androidx.compose.foundation.BorderStroke(1.dp, mobileBorder), ) { - Icon( - imageVector = Icons.Default.ArrowCircleDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "Message OpenClaw…", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + androidx.compose.foundation.layout.Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text("No messages yet", style = mobileHeadline, color = mobileText) + Text( + text = + if (healthOk) { + "Send the first prompt to start this session." + } else { + "Connect gateway first, then return to chat." + }, + style = mobileCallout, + color = mobileTextSecondary, + ) + } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt index effee6708e0d..413015bd14b7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt @@ -8,7 +8,11 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -19,8 +23,15 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import ai.openclaw.android.MainViewModel import ai.openclaw.android.chat.OutgoingAttachment +import ai.openclaw.android.ui.mobileAccent +import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption2 +import ai.openclaw.android.ui.mobileDanger +import ai.openclaw.android.ui.mobileText import java.io.ByteArrayOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -72,14 +83,19 @@ fun ChatSheetContent(viewModel: MainViewModel) { modifier = Modifier .fillMaxSize() - .padding(horizontal = 12.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { + if (!errorText.isNullOrBlank()) { + ChatErrorRail(errorText = errorText!!) + } + ChatMessageListCard( messages = messages, pendingRunCount = pendingRunCount, pendingToolCalls = pendingToolCalls, streamingAssistantText = streamingAssistantText, + healthOk = healthOk, modifier = Modifier.weight(1f, fill = true), ) @@ -90,7 +106,6 @@ fun ChatSheetContent(viewModel: MainViewModel) { healthOk = healthOk, thinkingLevel = thinkingLevel, pendingRunCount = pendingRunCount, - errorText = errorText, attachments = attachments, onPickImages = { pickImages.launch("image/*") }, onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, @@ -118,6 +133,25 @@ fun ChatSheetContent(viewModel: MainViewModel) { } } +@Composable +private fun ChatErrorRail(errorText: String) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = androidx.compose.ui.graphics.Color.White, + shape = RoundedCornerShape(12.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, mobileDanger), + ) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = "CHAT ERROR", + style = mobileCaption2.copy(letterSpacing = 0.6.sp), + color = mobileDanger, + ) + Text(text = errorText, style = mobileCallout, color = mobileText) + } + } +} + data class PendingImageAttachment( val id: String, val fileName: String, From 81ff074a510e47c899d4dbdb8669f3fcfd1b9af1 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 21:34:12 +0530 Subject: [PATCH 224/408] style(android-chat): align bubbles and markdown with RN --- .../openclaw/android/ui/chat/ChatMarkdown.kt | 24 +- .../android/ui/chat/ChatMessageViews.kt | 282 +++++++++++------- 2 files changed, 188 insertions(+), 118 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt index 77dba2275a41..e5d4def3fd98 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -28,13 +27,19 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption1 +import ai.openclaw.android.ui.mobileCodeBg +import ai.openclaw.android.ui.mobileCodeText +import ai.openclaw.android.ui.mobileTextSecondary import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @Composable fun ChatMarkdown(text: String, textColor: Color) { val blocks = remember(text) { splitMarkdown(text) } - val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow + val inlineCodeBg = mobileCodeBg + val inlineCodeColor = mobileCodeText Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { for (b in blocks) { @@ -43,8 +48,8 @@ fun ChatMarkdown(text: String, textColor: Color) { val trimmed = b.text.trimEnd() if (trimmed.isEmpty()) continue Text( - text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg), - style = MaterialTheme.typography.bodyMedium, + text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor), + style = mobileCallout, color = textColor, ) } @@ -126,7 +131,11 @@ private fun splitInlineImages(text: String): List { return out } -private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.graphics.Color): AnnotatedString { +private fun parseInlineMarkdown( + text: String, + inlineCodeBg: androidx.compose.ui.graphics.Color, + inlineCodeColor: androidx.compose.ui.graphics.Color, +): AnnotatedString { if (text.isEmpty()) return AnnotatedString("") val out = buildAnnotatedString { @@ -150,6 +159,7 @@ private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui. SpanStyle( fontFamily = FontFamily.Monospace, background = inlineCodeBg, + color = inlineCodeColor, ), ) { append(text.substring(i + 1, end)) @@ -208,8 +218,8 @@ private fun InlineBase64Image(base64: String, mimeType: String?) { Text( text = "Image unavailable", modifier = Modifier.padding(vertical = 2.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = mobileCaption1, + color = mobileTextSecondary, ) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt index bf2943275517..3f4250c3dbbb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt @@ -2,7 +2,8 @@ package ai.openclaw.android.ui.chat import android.graphics.BitmapFactory import android.util.Base64 -import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -12,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -24,55 +24,93 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.foundation.Image +import androidx.compose.ui.unit.sp import ai.openclaw.android.chat.ChatMessage import ai.openclaw.android.chat.ChatMessageContent import ai.openclaw.android.chat.ChatPendingToolCall import ai.openclaw.android.tools.ToolDisplayRegistry +import ai.openclaw.android.ui.mobileAccent +import ai.openclaw.android.ui.mobileAccentSoft +import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileBorderStrong +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption1 +import ai.openclaw.android.ui.mobileCaption2 +import ai.openclaw.android.ui.mobileCodeBg +import ai.openclaw.android.ui.mobileCodeText +import ai.openclaw.android.ui.mobileHeadline +import ai.openclaw.android.ui.mobileText +import ai.openclaw.android.ui.mobileTextSecondary +import ai.openclaw.android.ui.mobileWarning +import ai.openclaw.android.ui.mobileWarningSoft +import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import androidx.compose.ui.platform.LocalContext + +private data class ChatBubbleStyle( + val alignEnd: Boolean, + val containerColor: Color, + val borderColor: Color, + val roleColor: Color, +) @Composable fun ChatMessageBubble(message: ChatMessage) { - val isUser = message.role.lowercase() == "user" + val role = message.role.trim().lowercase(Locale.US) + val style = bubbleStyle(role) - // Filter to only displayable content parts (text with content, or base64 images) - val displayableContent = message.content.filter { part -> - when (part.type) { - "text" -> !part.text.isNullOrBlank() - else -> part.base64 != null + // Filter to only displayable content parts (text with content, or base64 images). + val displayableContent = + message.content.filter { part -> + when (part.type) { + "text" -> !part.text.isNullOrBlank() + else -> part.base64 != null + } } - } - // Skip rendering entirely if no displayable content if (displayableContent.isEmpty()) return + ChatBubbleContainer(style = style, roleLabel = roleLabel(role)) { + ChatMessageBody(content = displayableContent, textColor = mobileText) + } +} + +@Composable +private fun ChatBubbleContainer( + style: ChatBubbleStyle, + roleLabel: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, + modifier = modifier.fillMaxWidth(), + horizontalArrangement = if (style.alignEnd) Arrangement.End else Arrangement.Start, ) { Surface( - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, style.borderColor), + color = style.containerColor, tonalElevation = 0.dp, shadowElevation = 0.dp, - color = Color.Transparent, - modifier = Modifier.fillMaxWidth(0.92f), + modifier = Modifier.fillMaxWidth(0.90f), ) { - Box( - modifier = - Modifier - .background(bubbleBackground(isUser)) - .padding(horizontal = 12.dp, vertical = 10.dp), + Column( + modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), ) { - val textColor = textColorOverBubble(isUser) - ChatMessageBody(content = displayableContent, textColor = textColor) + Text( + text = roleLabel, + style = mobileCaption2.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp), + color = style.roleColor, + ) + content() } } } @@ -80,7 +118,7 @@ fun ChatMessageBubble(message: ChatMessage) { @Composable private fun ChatMessageBody(content: List, textColor: Color) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { for (part in content) { when (part.type) { "text" -> { @@ -98,19 +136,16 @@ private fun ChatMessageBody(content: List, textColor: Color) @Composable fun ChatTypingIndicatorBubble() { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainer, + ChatBubbleContainer( + style = bubbleStyle("assistant"), + roleLabel = roleLabel("assistant"), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - DotPulse() - Text("Thinking…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } + DotPulse(color = mobileTextSecondary) + Text("Thinking...", style = mobileCallout, color = mobileTextSecondary) } } } @@ -122,38 +157,37 @@ fun ChatPendingToolsBubble(toolCalls: List) { remember(toolCalls, context) { toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) } } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("Running tools…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface) - for (display in displays.take(6)) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + + ChatBubbleContainer( + style = bubbleStyle("assistant"), + roleLabel = "TOOLS", + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Running tools...", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + for (display in displays.take(6)) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + "${display.emoji} ${display.label}", + style = mobileCallout, + color = mobileTextSecondary, + fontFamily = FontFamily.Monospace, + ) + display.detailLine?.let { detail -> Text( - "${display.emoji} ${display.label}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + detail, + style = mobileCaption1, + color = mobileTextSecondary, fontFamily = FontFamily.Monospace, ) - display.detailLine?.let { detail -> - Text( - detail, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = FontFamily.Monospace, - ) - } } } - if (toolCalls.size > 6) { - Text( - "… +${toolCalls.size - 6} more", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + } + if (toolCalls.size > 6) { + Text( + text = "... +${toolCalls.size - 6} more", + style = mobileCaption1, + color = mobileTextSecondary, + ) } } } @@ -161,37 +195,47 @@ fun ChatPendingToolsBubble(toolCalls: List) { @Composable fun ChatStreamingAssistantBubble(text: String) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { - ChatMarkdown(text = text, textColor = MaterialTheme.colorScheme.onSurface) - } - } + ChatBubbleContainer( + style = bubbleStyle("assistant").copy(borderColor = mobileAccent), + roleLabel = "ASSISTANT · LIVE", + ) { + ChatMarkdown(text = text, textColor = mobileText) } } -@Composable -private fun bubbleBackground(isUser: Boolean): Brush { - return if (isUser) { - Brush.linearGradient( - colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary.copy(alpha = 0.78f)), - ) - } else { - Brush.linearGradient( - colors = listOf(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.surfaceContainerHigh), - ) +private fun bubbleStyle(role: String): ChatBubbleStyle { + return when (role) { + "user" -> + ChatBubbleStyle( + alignEnd = true, + containerColor = mobileAccentSoft, + borderColor = mobileAccent, + roleColor = mobileAccent, + ) + + "system" -> + ChatBubbleStyle( + alignEnd = false, + containerColor = mobileWarningSoft, + borderColor = mobileWarning.copy(alpha = 0.45f), + roleColor = mobileWarning, + ) + + else -> + ChatBubbleStyle( + alignEnd = false, + containerColor = Color.White, + borderColor = mobileBorderStrong, + roleColor = mobileTextSecondary, + ) } } -@Composable -private fun textColorOverBubble(isUser: Boolean): Color { - return if (isUser) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurface +private fun roleLabel(role: String): String { + return when (role) { + "user" -> "USER" + "system" -> "SYSTEM" + else -> "ASSISTANT" } } @@ -216,48 +260,64 @@ private fun ChatBase64Image(base64: String, mimeType: String?) { } if (image != null) { - Image( - bitmap = image!!, - contentDescription = mimeType ?: "attachment", - contentScale = ContentScale.Fit, + Surface( + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, mobileBorder), + color = Color.White, modifier = Modifier.fillMaxWidth(), - ) + ) { + Image( + bitmap = image!!, + contentDescription = mimeType ?: "attachment", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth(), + ) + } } else if (failed) { - Text("Unsupported attachment", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("Unsupported attachment", style = mobileCaption1, color = mobileTextSecondary) } } @Composable -private fun DotPulse() { +private fun DotPulse(color: Color) { Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { - PulseDot(alpha = 0.38f) - PulseDot(alpha = 0.62f) - PulseDot(alpha = 0.90f) + PulseDot(alpha = 0.38f, color = color) + PulseDot(alpha = 0.62f, color = color) + PulseDot(alpha = 0.90f, color = color) } } @Composable -private fun PulseDot(alpha: Float) { +private fun PulseDot(alpha: Float, color: Color) { Surface( modifier = Modifier.size(6.dp).alpha(alpha), shape = CircleShape, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = color, ) {} } @Composable fun ChatCodeBlock(code: String, language: String?) { Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerLowest, + shape = RoundedCornerShape(8.dp), + color = mobileCodeBg, + border = BorderStroke(1.dp, Color(0xFF2B2E35)), modifier = Modifier.fillMaxWidth(), ) { - Text( - text = code.trimEnd(), - modifier = Modifier.padding(10.dp), - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (!language.isNullOrBlank()) { + Text( + text = language.uppercase(Locale.US), + style = mobileCaption2.copy(letterSpacing = 0.4.sp), + color = mobileTextSecondary, + ) + } + Text( + text = code.trimEnd(), + fontFamily = FontFamily.Monospace, + style = mobileCallout, + color = mobileCodeText, + ) + } } } From 577c554150e87ebb7b7ea02b268dee491451b6a1 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 21:34:15 +0530 Subject: [PATCH 225/408] style(android-chat): redesign composer controls and actions --- .../openclaw/android/ui/chat/ChatComposer.kt | 398 ++++++++++++------ 1 file changed, 267 insertions(+), 131 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt index 07ba769697df..d87954644398 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt @@ -1,31 +1,35 @@ package ai.openclaw.android.ui.chat +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.horizontalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -37,9 +41,24 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import ai.openclaw.android.chat.ChatSessionEntry +import ai.openclaw.android.ui.mobileAccent +import ai.openclaw.android.ui.mobileAccentSoft +import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileBorderStrong +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption1 +import ai.openclaw.android.ui.mobileDanger +import ai.openclaw.android.ui.mobileHeadline +import ai.openclaw.android.ui.mobileSurface +import ai.openclaw.android.ui.mobileText +import ai.openclaw.android.ui.mobileTextSecondary +import ai.openclaw.android.ui.mobileTextTertiary @Composable fun ChatComposer( @@ -49,7 +68,6 @@ fun ChatComposer( healthOk: Boolean, thinkingLevel: String, pendingRunCount: Int, - errorText: String?, attachments: List, onPickImages: () -> Unit, onRemoveAttachment: (id: String) -> Unit, @@ -61,154 +79,237 @@ fun ChatComposer( ) { var input by rememberSaveable { mutableStateOf("") } var showThinkingMenu by remember { mutableStateOf(false) } - var showSessionMenu by remember { mutableStateOf(false) } val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) - val currentSessionLabel = friendlySessionName( - sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey - ) + val currentSessionLabel = + friendlySessionName(sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey) val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk + val sendBusy = pendingRunCount > 0 - Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - ) { - Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box { - FilledTonalButton( - onClick = { showSessionMenu = true }, - contentPadding = ButtonDefaults.ContentPadding, - ) { - Text(currentSessionLabel, maxLines = 1, overflow = TextOverflow.Ellipsis) - } + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "SESSION", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp), + color = mobileTextSecondary, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + text = currentSessionLabel, + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = mobileText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + ConnectionPill(healthOk = healthOk) + } + } - DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) { - for (entry in sessionOptions) { - DropdownMenuItem( - text = { Text(friendlySessionName(entry.displayName ?: entry.key)) }, - onClick = { - onSelectSession(entry.key) - showSessionMenu = false - }, - trailingIcon = { - if (entry.key == sessionKey) { - Text("✓") - } else { - Spacer(modifier = Modifier.width(10.dp)) - } - }, - ) - } - } + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (entry in sessionOptions) { + val active = entry.key == sessionKey + Surface( + onClick = { onSelectSession(entry.key) }, + shape = RoundedCornerShape(14.dp), + color = if (active) mobileAccent else Color.White, + border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Text( + text = friendlySessionName(entry.displayName ?: entry.key), + style = mobileCaption1.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold), + color = if (active) Color.White else mobileText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) } + } + } - Box { - FilledTonalButton( - onClick = { showThinkingMenu = true }, - contentPadding = ButtonDefaults.ContentPadding, + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(modifier = Modifier.weight(1f)) { + Surface( + onClick = { showThinkingMenu = true }, + shape = RoundedCornerShape(14.dp), + color = mobileAccentSoft, + border = BorderStroke(1.dp, mobileBorderStrong), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Text("🧠 ${thinkingLabel(thinkingLevel)}", maxLines = 1) - } - - DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) { - ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + Text( + text = "Thinking: ${thinkingLabel(thinkingLevel)}", + style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), + color = mobileText, + ) + Icon(Icons.Default.ArrowDropDown, contentDescription = "Select thinking level", tint = mobileTextSecondary) } } - FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh") - } - - FilledTonalIconButton(onClick = onPickImages, modifier = Modifier.size(42.dp)) { - Icon(Icons.Default.AttachFile, contentDescription = "Add image") + DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) { + ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } } } - if (attachments.isNotEmpty()) { - AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment) - } + SecondaryActionButton( + label = "Attach", + icon = Icons.Default.AttachFile, + enabled = true, + onClick = onPickImages, + ) + } + + if (attachments.isNotEmpty()) { + AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment) + } - OutlinedTextField( - value = input, - onValueChange = { input = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Message OpenClaw…") }, - minLines = 2, - maxLines = 6, + HorizontalDivider(color = mobileBorder) + + Text( + text = "MESSAGE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.9.sp), + color = mobileTextSecondary, + ) + + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.fillMaxWidth().height(108.dp), + placeholder = { Text("Type a message", style = mobileBodyStyle(), color = mobileTextTertiary) }, + minLines = 3, + maxLines = 6, + textStyle = mobileBodyStyle().copy(color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = chatTextFieldColors(), + ) + + if (!healthOk) { + Text( + text = "Gateway is offline. Connect first in the Connect tab.", + style = mobileCallout, + color = ai.openclaw.android.ui.mobileWarning, ) + } - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk) - Spacer(modifier = Modifier.weight(1f)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SecondaryActionButton( + label = "Refresh", + icon = Icons.Default.Refresh, + enabled = true, + onClick = onRefresh, + ) - if (pendingRunCount > 0) { - FilledTonalIconButton( - onClick = onAbort, - colors = - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = Color(0x33E74C3C), - contentColor = Color(0xFFE74C3C), - ), - ) { - Icon(Icons.Default.Stop, contentDescription = "Abort") - } - } else { - FilledTonalIconButton(onClick = { - val text = input - input = "" - onSend(text) - }, enabled = canSend) { - Icon(Icons.Default.ArrowUpward, contentDescription = "Send") - } - } + SecondaryActionButton( + label = "Abort", + icon = Icons.Default.Stop, + enabled = pendingRunCount > 0, + onClick = onAbort, + ) } - if (!errorText.isNullOrBlank()) { - Text( - text = errorText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - maxLines = 2, - ) + Button( + onClick = { + val text = input + input = "" + onSend(text) + }, + enabled = canSend, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = mobileAccent, + contentColor = Color.White, + disabledContainerColor = mobileBorderStrong, + disabledContentColor = mobileTextTertiary, + ), + border = BorderStroke(1.dp, if (canSend) Color(0xFF154CAD) else mobileBorderStrong), + ) { + if (sendBusy) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = Color.White) + } else { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp)) + } + Spacer(modifier = Modifier.width(8.dp)) + Text("Send", style = mobileHeadline.copy(fontWeight = FontWeight.Bold)) } } } } @Composable -private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) { +private fun ConnectionPill(healthOk: Boolean) { Surface( shape = RoundedCornerShape(999.dp), - color = MaterialTheme.colorScheme.surfaceContainerHighest, + color = if (healthOk) ai.openclaw.android.ui.mobileSuccessSoft else ai.openclaw.android.ui.mobileWarningSoft, + border = + BorderStroke( + 1.dp, + if (healthOk) ai.openclaw.android.ui.mobileSuccess.copy(alpha = 0.35f) else ai.openclaw.android.ui.mobileWarning.copy(alpha = 0.35f), + ), ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - modifier = Modifier.size(7.dp), - shape = androidx.compose.foundation.shape.CircleShape, - color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12), - ) {} - Text(sessionLabel, style = MaterialTheme.typography.labelSmall) - Text( - if (healthOk) "Connected" else "Connecting…", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Text( + text = if (healthOk) "Connected" else "Offline", + style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), + color = if (healthOk) ai.openclaw.android.ui.mobileSuccess else ai.openclaw.android.ui.mobileWarning, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} + +@Composable +private fun SecondaryActionButton( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + enabled: Boolean, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier.height(44.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = mobileTextSecondary, + disabledContainerColor = Color.White, + disabledContentColor = mobileTextTertiary, + ), + border = BorderStroke(1.dp, mobileBorderStrong), + contentPadding = ButtonDefaults.ContentPadding, + ) { + Icon(icon, contentDescription = label, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(5.dp)) + Text( + text = label, + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = if (enabled) mobileTextSecondary else mobileTextTertiary, + ) } } @@ -220,14 +321,14 @@ private fun ThinkingMenuItem( onDismiss: () -> Unit, ) { DropdownMenuItem( - text = { Text(thinkingLabel(value)) }, + text = { Text(thinkingLabel(value), style = mobileCallout, color = mobileText) }, onClick = { onSet(value) onDismiss() }, trailingIcon = { if (value == current.trim().lowercase()) { - Text("✓") + Text("✓", style = mobileCallout, color = mobileAccent) } else { Spacer(modifier = Modifier.width(10.dp)) } @@ -266,20 +367,55 @@ private fun AttachmentsStrip( private fun AttachmentChip(fileName: String, onRemove: () -> Unit) { Surface( shape = RoundedCornerShape(999.dp), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.10f), + color = mobileAccentSoft, + border = BorderStroke(1.dp, mobileBorderStrong), ) { Row( modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(text = fileName, style = MaterialTheme.typography.bodySmall, maxLines = 1) - FilledTonalIconButton( + Text( + text = fileName, + style = mobileCaption1, + color = mobileText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Surface( onClick = onRemove, - modifier = Modifier.size(30.dp), + shape = RoundedCornerShape(999.dp), + color = Color.White, + border = BorderStroke(1.dp, mobileBorderStrong), ) { - Text("×") + Text( + text = "×", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold), + color = mobileTextSecondary, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + ) } } } } + +@Composable +private fun chatTextFieldColors() = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = mobileSurface, + unfocusedContainerColor = mobileSurface, + focusedBorderColor = mobileAccent, + unfocusedBorderColor = mobileBorder, + focusedTextColor = mobileText, + unfocusedTextColor = mobileText, + cursorColor = mobileAccent, + ) + +@Composable +private fun mobileBodyStyle() = + MaterialTheme.typography.bodyMedium.copy( + fontFamily = ai.openclaw.android.ui.mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 22.sp, + ) From 94f426b29e53713e3ad6014914047a5c876cacb7 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 21:36:58 +0530 Subject: [PATCH 226/408] fix(android-nav): hide tab bar while keyboard is open --- .../openclaw/android/ui/PostOnboardingTabs.kt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt index ae153ad9c14f..c43159988b66 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt @@ -9,12 +9,15 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding @@ -70,8 +73,10 @@ private enum class StatusVisual { } @Composable +@OptIn(ExperimentalLayoutApi::class) fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) { var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) } + val imeVisible = WindowInsets.isImeVisible val statusText by viewModel.statusText.collectAsState() val isConnected by viewModel.isConnected.collectAsState() @@ -99,10 +104,12 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) ) }, bottomBar = { - BottomTabBar( - activeTab = activeTab, - onSelect = { activeTab = it }, - ) + if (!imeVisible) { + BottomTabBar( + activeTab = activeTab, + onSelect = { activeTab = it }, + ) + } }, ) { innerPadding -> Box( @@ -218,7 +225,7 @@ private fun BottomTabBar( activeTab: HomeTab, onSelect: (HomeTab) -> Unit, ) { - val safeInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + val safeInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) Box( modifier = From bb27884474d939c162dbb2f31623908b6525f470 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 21:43:56 +0530 Subject: [PATCH 227/408] feat(android-tabs): add coming-soon voice and screen tabs --- .../openclaw/android/ui/PostOnboardingTabs.kt | 101 +++--------------- 1 file changed, 14 insertions(+), 87 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt index c43159988b66..64b8100a44fe 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt @@ -1,9 +1,5 @@ package ai.openclaw.android.ui -import android.Manifest -import android.content.pm.PackageManager -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement @@ -30,8 +26,6 @@ import androidx.compose.material.icons.filled.ChatBubble import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.RecordVoiceOver import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -47,10 +41,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat import ai.openclaw.android.MainViewModel private enum class HomeTab( @@ -122,8 +114,8 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) when (activeTab) { HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel) HomeTab.Chat -> ChatSheet(viewModel = viewModel) - HomeTab.Voice -> VoiceTabScreen(viewModel = viewModel) - HomeTab.Screen -> ScreenTabScreen(viewModel = viewModel) + HomeTab.Voice -> ComingSoonTabScreen(label = "VOICE", title = "Coming soon", description = "Voice mode is coming soon.") + HomeTab.Screen -> ComingSoonTabScreen(label = "SCREEN", title = "Coming soon", description = "Screen mode is coming soon.") HomeTab.Settings -> SettingsSheet(viewModel = viewModel) } } @@ -279,85 +271,20 @@ private fun BottomTabBar( } @Composable -private fun VoiceTabScreen(viewModel: MainViewModel) { - val context = LocalContext.current - val talkEnabled by viewModel.talkEnabled.collectAsState() - val talkStatusText by viewModel.talkStatusText.collectAsState() - val talkIsListening by viewModel.talkIsListening.collectAsState() - val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState() - val seamColorArgb by viewModel.seamColorArgb.collectAsState() - - val seamColor = remember(seamColorArgb) { Color(seamColorArgb) } - - val audioPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) { - viewModel.setTalkEnabled(true) - } - } - +private fun ComingSoonTabScreen( + label: String, + title: String, + description: String, +) { Box(modifier = Modifier.fillMaxSize().padding(horizontal = 22.dp, vertical = 18.dp)) { - if (talkEnabled) { - TalkOrbOverlay( - seamColor = seamColor, - statusText = talkStatusText, - isListening = talkIsListening, - isSpeaking = talkIsSpeaking, - modifier = Modifier.align(Alignment.Center), - ) - } else { - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text("VOICE", style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent) - Text("Talk Mode", style = mobileTitle1, color = mobileText) - Text( - "Enable voice controls and watch live listening/speaking state.", - style = mobileBody, - color = mobileTextSecondary, - ) - } - } - - Button( - onClick = { - if (talkEnabled) { - viewModel.setTalkEnabled(false) - return@Button - } - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (micOk) { - viewModel.setTalkEnabled(true) - } else { - audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } - }, - modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(), - shape = RoundedCornerShape(14.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = if (talkEnabled) mobileDanger else mobileAccent, - contentColor = Color.White, - ), + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Text( - if (talkEnabled) "Disable Talk Mode" else "Enable Talk Mode", - style = mobileHeadline.copy(fontWeight = FontWeight.Bold), - ) + Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent) + Text(title, style = mobileTitle1, color = mobileText) + Text(description, style = mobileBody, color = mobileTextSecondary) } } } - -@Composable -private fun ScreenTabScreen(viewModel: MainViewModel) { - val cameraFlashToken by viewModel.cameraFlashToken.collectAsState() - - Box(modifier = Modifier.fillMaxSize()) { - CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize()) - CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize()) - } -} From baf98a87f607ee887ddd25909a06283f0b69eab5 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 21:43:59 +0530 Subject: [PATCH 228/408] refactor(android-settings): remove gateway controls duplicated in connect --- .../ai/openclaw/android/ui/SettingsSheet.kt | 255 +----------------- 1 file changed, 3 insertions(+), 252 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt index d04dd5cab958..2a6219578c70 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt @@ -12,7 +12,6 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -37,13 +36,9 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.AlertDialog import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -52,7 +47,6 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -69,14 +63,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import ai.openclaw.android.BuildConfig import ai.openclaw.android.LocationMode import ai.openclaw.android.MainViewModel -import ai.openclaw.android.NodeForegroundService import ai.openclaw.android.VoiceWakeMode import ai.openclaw.android.WakeWords @@ -93,22 +85,10 @@ fun SettingsSheet(viewModel: MainViewModel) { val voiceWakeMode by viewModel.voiceWakeMode.collectAsState() val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() val isConnected by viewModel.isConnected.collectAsState() - val manualEnabled by viewModel.manualEnabled.collectAsState() - val manualHost by viewModel.manualHost.collectAsState() - val manualPort by viewModel.manualPort.collectAsState() - val manualTls by viewModel.manualTls.collectAsState() - val gatewayToken by viewModel.gatewayToken.collectAsState() val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState() - val statusText by viewModel.statusText.collectAsState() - val serverName by viewModel.serverName.collectAsState() - val remoteAddress by viewModel.remoteAddress.collectAsState() - val gateways by viewModel.gateways.collectAsState() - val discoveryStatusText by viewModel.discoveryStatusText.collectAsState() - val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() val listState = rememberLazyListState() val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } - val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current var wakeWordsHadFocus by remember { mutableStateOf(false) } val deviceModel = @@ -136,31 +116,6 @@ fun SettingsSheet(viewModel: MainViewModel) { leadingIconColor = mobileTextSecondary, ) - if (pendingTrust != null) { - val prompt = pendingTrust!! - AlertDialog( - onDismissRequest = { viewModel.declineGatewayTrustPrompt() }, - title = { Text("Trust this gateway?") }, - text = { - Text( - "First-time TLS connection.\n\n" + - "Verify this SHA-256 fingerprint out-of-band before trusting:\n" + - prompt.fingerprintSha256, - ) - }, - confirmButton = { - TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) { - Text("Trust and connect") - } - }, - dismissButton = { - TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) { - Text("Cancel") - } - }, - ) - } - LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } val commitWakeWords = { val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords) @@ -289,22 +244,6 @@ fun SettingsSheet(viewModel: MainViewModel) { } } - val visibleGateways = - if (isConnected && remoteAddress != null) { - gateways.filterNot { "${it.host}:${it.port}" == remoteAddress } - } else { - gateways - } - - val gatewayDiscoveryFooterText = - if (visibleGateways.isEmpty()) { - discoveryStatusText - } else if (isConnected) { - "Discovery active • ${visibleGateways.size} other gateway${if (visibleGateways.size == 1) "" else "s"} found" - } else { - "Discovery active • ${visibleGateways.size} gateway${if (visibleGateways.size == 1) "" else "s"} found" - } - Box( modifier = Modifier @@ -329,9 +268,9 @@ fun SettingsSheet(viewModel: MainViewModel) { style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), color = mobileAccent, ) - Text("Device + Gateway Configuration", style = mobileTitle2, color = mobileText) + Text("Device Configuration", style = mobileTitle2, color = mobileText) Text( - "Manage capabilities, connection mode, permissions, and diagnostics.", + "Manage capabilities, permissions, and diagnostics.", style = mobileCallout, color = mobileTextSecondary, ) @@ -339,7 +278,7 @@ fun SettingsSheet(viewModel: MainViewModel) { } item { HorizontalDivider(color = mobileBorder) } - // Order parity: Node → Gateway → Voice → Camera → Messaging → Location → Screen. + // Order parity: Node → Voice → Camera → Messaging → Location → Screen. item { Text( "NODE", @@ -363,194 +302,6 @@ fun SettingsSheet(viewModel: MainViewModel) { item { HorizontalDivider(color = mobileBorder) } - // Gateway - item { - Text( - "GATEWAY", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), - color = mobileAccent, - ) - } - item { ListItem(modifier = settingsRowModifier(), colors = listItemColors, headlineContent = { Text("Status", style = mobileHeadline) }, supportingContent = { Text(statusText, style = mobileCallout) }) } - if (serverName != null) { - item { ListItem(modifier = settingsRowModifier(), colors = listItemColors, headlineContent = { Text("Server", style = mobileHeadline) }, supportingContent = { Text(serverName!!, style = mobileCallout) }) } - } - if (remoteAddress != null) { - item { ListItem(modifier = settingsRowModifier(), colors = listItemColors, headlineContent = { Text("Address", style = mobileHeadline) }, supportingContent = { Text(remoteAddress!!, style = mobileCallout.copy(fontFamily = FontFamily.Monospace)) }) } - } - item { - // UI sanity: "Disconnect" only when we have an active remote. - if (isConnected && remoteAddress != null) { - Button( - onClick = { - viewModel.disconnect() - NodeForegroundService.stop(context) - }, - colors = settingsDangerButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text("Disconnect", style = mobileHeadline.copy(fontWeight = FontWeight.Bold)) - } - } - } - - item { HorizontalDivider(color = mobileBorder) } - - if (!isConnected || visibleGateways.isNotEmpty()) { - item { - Text( - if (isConnected) "Other Gateways" else "Discovered Gateways", - style = mobileHeadline, - color = mobileText, - ) - } - if (!isConnected && visibleGateways.isEmpty()) { - item { Text("No gateways found yet.", style = mobileCallout, color = mobileTextSecondary) } - } else { - items(items = visibleGateways, key = { it.stableId }) { gateway -> - val detailLines = - buildList { - add("IP: ${gateway.host}:${gateway.port}") - gateway.lanHost?.let { add("LAN: $it") } - gateway.tailnetDns?.let { add("Tailnet: $it") } - if (gateway.gatewayPort != null || gateway.canvasPort != null) { - val gw = (gateway.gatewayPort ?: gateway.port).toString() - val canvas = gateway.canvasPort?.toString() ?: "—" - add("Ports: gw $gw · canvas $canvas") - } - } - ListItem( - modifier = settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text(gateway.name, style = mobileHeadline) }, - supportingContent = { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - detailLines.forEach { line -> - Text(line, style = mobileCallout, color = mobileTextSecondary) - } - } - }, - trailingContent = { - Button( - onClick = { - NodeForegroundService.start(context) - viewModel.connect(gateway) - }, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text("Connect", style = mobileCallout.copy(fontWeight = FontWeight.Bold)) - } - }, - ) - } - } - item { - Text( - gatewayDiscoveryFooterText, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = mobileCaption1, - color = mobileTextSecondary, - ) - } - } - - item { HorizontalDivider(color = mobileBorder) } - - item { - ListItem( - modifier = settingsRowModifier().then(Modifier.clickable { setAdvancedExpanded(!advancedExpanded) }), - colors = listItemColors, - headlineContent = { Text("Advanced", style = mobileHeadline) }, - supportingContent = { Text("Manual gateway connection", style = mobileCallout) }, - trailingContent = { - Icon( - imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = if (advancedExpanded) "Collapse" else "Expand", - tint = mobileTextSecondary, - ) - }, - ) - } - item { - AnimatedVisibility(visible = advancedExpanded) { - Column( - verticalArrangement = Arrangement.spacedBy(10.dp), - modifier = - Modifier - .fillMaxWidth() - .border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp)) - .background(mobileSurface, RoundedCornerShape(14.dp)) - .padding(12.dp), - ) { - ListItem( - modifier = settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Use Manual Gateway", style = mobileHeadline) }, - supportingContent = { Text("Use this when discovery is blocked.", style = mobileCallout) }, - trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) }, - ) - - OutlinedTextField( - value = manualHost, - onValueChange = viewModel::setManualHost, - label = { Text("Host", style = mobileCaption1, color = mobileTextSecondary) }, - modifier = Modifier.fillMaxWidth(), - enabled = manualEnabled, - textStyle = mobileBody.copy(color = mobileText), - colors = settingsTextFieldColors(), - ) - OutlinedTextField( - value = manualPort.toString(), - onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) }, - label = { Text("Port", style = mobileCaption1, color = mobileTextSecondary) }, - modifier = Modifier.fillMaxWidth(), - enabled = manualEnabled, - textStyle = mobileBody.copy(fontFamily = FontFamily.Monospace, color = mobileText), - colors = settingsTextFieldColors(), - ) - OutlinedTextField( - value = gatewayToken, - onValueChange = viewModel::setGatewayToken, - label = { Text("Gateway Token", style = mobileCaption1, color = mobileTextSecondary) }, - modifier = Modifier.fillMaxWidth(), - enabled = manualEnabled, - singleLine = true, - textStyle = mobileBody.copy(color = mobileText), - colors = settingsTextFieldColors(), - ) - ListItem( - modifier = settingsRowModifier().alpha(if (manualEnabled) 1f else 0.5f), - colors = listItemColors, - headlineContent = { Text("Require TLS", style = mobileHeadline) }, - supportingContent = { Text("Pin the gateway certificate on first connect.", style = mobileCallout) }, - trailingContent = { Switch(checked = manualTls, onCheckedChange = viewModel::setManualTls, enabled = manualEnabled) }, - ) - - val hostOk = manualHost.trim().isNotEmpty() - val portOk = manualPort in 1..65535 - Button( - onClick = { - NodeForegroundService.start(context) - viewModel.connectManual() - }, - enabled = manualEnabled && hostOk && portOk, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text("Connect (Manual)", style = mobileCallout.copy(fontWeight = FontWeight.Bold)) - } - - TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) { - Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent) - } - } - } - } - - item { HorizontalDivider(color = mobileBorder) } - // Voice item { Text( From 75f145ebccb05947e2f6d85e0ccaea0f9b515607 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 21:53:27 +0530 Subject: [PATCH 229/408] docs(android): document alpha rebuild status and feature checklist --- apps/android/README.md | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/apps/android/README.md b/apps/android/README.md index c2ae5a2179bf..5e4d32359e0a 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -1,13 +1,26 @@ -## OpenClaw Node (Android) (internal) +## OpenClaw Android App -Modern Android node app: connects to the **Gateway WebSocket** (`_openclaw-gw._tcp`) and exposes **Canvas + Chat + Camera**. +Status: **extremely alpha**. The app is actively being rebuilt from the ground up. -Notes: -- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action). -- Chat always uses the shared session key **`main`** (same session across iOS/macOS/WebChat/Android). -- Supports modern Android only (`minSdk 31`, Kotlin + Jetpack Compose). +### Rebuild Checklist + +- [x] New 4-step onboarding flow +- [x] Connect tab with `Setup Code` + `Manual` modes +- [x] Encrypted persistence for gateway setup/auth state +- [x] Chat UI restyled +- [x] Settings UI restyled and de-duplicated (gateway controls moved to Connect) +- [ ] QR code scanning in onboarding +- [ ] Performance improvements +- [ ] Streaming support in chat UI +- [ ] Request camera/location and other permissions in onboarding/settings flow +- [ ] Push notifications for gateway/chat status updates +- [ ] Security hardening (biometric lock, token handling, safer defaults) +- [ ] Voice tab full functionality +- [ ] Screen tab full functionality +- [ ] Full end-to-end QA and release hardening ## Open in Android Studio + - Open the folder `apps/android`. ## Build / Run @@ -23,16 +36,19 @@ cd apps/android ## Connect / Pair -1) Start the gateway (on your “master” machine): +1) Start the gateway (on your main machine): + ```bash pnpm openclaw gateway --port 18789 --verbose ``` 2) In the Android app: -- Open **Settings** -- Either select a discovered gateway under **Discovered Gateways**, or use **Advanced → Manual Gateway** (host + port). + +- Open the **Connect** tab. +- Use **Setup Code** or **Manual** mode to connect. 3) Approve pairing (on the gateway machine): + ```bash openclaw nodes pending openclaw nodes approve @@ -49,3 +65,8 @@ More details: `docs/platforms/android.md`. - Camera: - `CAMERA` for `camera.snap` and `camera.clip` - `RECORD_AUDIO` for `camera.clip` when `includeAudio=true` + +## Contributions + +This Android app is currently being rebuilt. +Maintainer: @obviyus. For issues/questions/contributions, please open an issue or reach out on Discord. From e11e329238532b533034e0fad8c8c8fbda50b0e5 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 21:57:58 +0530 Subject: [PATCH 230/408] refactor(android-chat): move thread selector above composer --- .../openclaw/android/ui/chat/ChatComposer.kt | 85 +------------- .../android/ui/chat/ChatSheetContent.kt | 106 +++++++++++++++++- 2 files changed, 105 insertions(+), 86 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt index d87954644398..7f71995906bd 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt @@ -41,19 +41,16 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import ai.openclaw.android.chat.ChatSessionEntry import ai.openclaw.android.ui.mobileAccent import ai.openclaw.android.ui.mobileAccentSoft import ai.openclaw.android.ui.mobileBorder import ai.openclaw.android.ui.mobileBorderStrong import ai.openclaw.android.ui.mobileCallout import ai.openclaw.android.ui.mobileCaption1 -import ai.openclaw.android.ui.mobileDanger import ai.openclaw.android.ui.mobileHeadline import ai.openclaw.android.ui.mobileSurface import ai.openclaw.android.ui.mobileText @@ -62,9 +59,6 @@ import ai.openclaw.android.ui.mobileTextTertiary @Composable fun ChatComposer( - sessionKey: String, - sessions: List, - mainSessionKey: String, healthOk: Boolean, thinkingLevel: String, pendingRunCount: Int, @@ -72,7 +66,6 @@ fun ChatComposer( onPickImages: () -> Unit, onRemoveAttachment: (id: String) -> Unit, onSetThinkingLevel: (level: String) -> Unit, - onSelectSession: (sessionKey: String) -> Unit, onRefresh: () -> Unit, onAbort: () -> Unit, onSend: (text: String) -> Unit, @@ -80,62 +73,10 @@ fun ChatComposer( var input by rememberSaveable { mutableStateOf("") } var showThinkingMenu by remember { mutableStateOf(false) } - val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) - val currentSessionLabel = - friendlySessionName(sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey) - val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk val sendBusy = pendingRunCount > 0 Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "SESSION", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp), - color = mobileTextSecondary, - ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - Text( - text = currentSessionLabel, - style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), - color = mobileText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - ConnectionPill(healthOk = healthOk) - } - } - - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - for (entry in sessionOptions) { - val active = entry.key == sessionKey - Surface( - onClick = { onSelectSession(entry.key) }, - shape = RoundedCornerShape(14.dp), - color = if (active) mobileAccent else Color.White, - border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong), - tonalElevation = 0.dp, - shadowElevation = 0.dp, - ) { - Text( - text = friendlySessionName(entry.displayName ?: entry.key), - style = mobileCaption1.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold), - color = if (active) Color.White else mobileText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - ) - } - } - } - Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -193,10 +134,10 @@ fun ChatComposer( OutlinedTextField( value = input, onValueChange = { input = it }, - modifier = Modifier.fillMaxWidth().height(108.dp), + modifier = Modifier.fillMaxWidth().height(92.dp), placeholder = { Text("Type a message", style = mobileBodyStyle(), color = mobileTextTertiary) }, - minLines = 3, - maxLines = 6, + minLines = 2, + maxLines = 5, textStyle = mobileBodyStyle().copy(color = mobileText), shape = RoundedCornerShape(14.dp), colors = chatTextFieldColors(), @@ -261,26 +202,6 @@ fun ChatComposer( } } -@Composable -private fun ConnectionPill(healthOk: Boolean) { - Surface( - shape = RoundedCornerShape(999.dp), - color = if (healthOk) ai.openclaw.android.ui.mobileSuccessSoft else ai.openclaw.android.ui.mobileWarningSoft, - border = - BorderStroke( - 1.dp, - if (healthOk) ai.openclaw.android.ui.mobileSuccess.copy(alpha = 0.35f) else ai.openclaw.android.ui.mobileWarning.copy(alpha = 0.35f), - ), - ) { - Text( - text = if (healthOk) "Connected" else "Offline", - style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), - color = if (healthOk) ai.openclaw.android.ui.mobileSuccess else ai.openclaw.android.ui.mobileWarning, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), - ) - } -} - @Composable private fun SecondaryActionButton( label: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt index 413015bd14b7..d1c2743ef041 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt @@ -5,11 +5,15 @@ import android.net.Uri import android.util.Base64 import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -21,17 +25,28 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import ai.openclaw.android.MainViewModel +import ai.openclaw.android.chat.ChatSessionEntry import ai.openclaw.android.chat.OutgoingAttachment import ai.openclaw.android.ui.mobileAccent import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileBorderStrong import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption1 import ai.openclaw.android.ui.mobileCaption2 import ai.openclaw.android.ui.mobileDanger +import ai.openclaw.android.ui.mobileSuccess +import ai.openclaw.android.ui.mobileSuccessSoft import ai.openclaw.android.ui.mobileText +import ai.openclaw.android.ui.mobileTextSecondary +import ai.openclaw.android.ui.mobileWarning +import ai.openclaw.android.ui.mobileWarningSoft import java.io.ByteArrayOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -86,6 +101,14 @@ fun ChatSheetContent(viewModel: MainViewModel) { .padding(horizontal = 20.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + ChatThreadSelector( + sessionKey = sessionKey, + sessions = sessions, + mainSessionKey = mainSessionKey, + healthOk = healthOk, + onSelectSession = { key -> viewModel.switchChatSession(key) }, + ) + if (!errorText.isNullOrBlank()) { ChatErrorRail(errorText = errorText!!) } @@ -100,9 +123,6 @@ fun ChatSheetContent(viewModel: MainViewModel) { ) ChatComposer( - sessionKey = sessionKey, - sessions = sessions, - mainSessionKey = mainSessionKey, healthOk = healthOk, thinkingLevel = thinkingLevel, pendingRunCount = pendingRunCount, @@ -110,7 +130,6 @@ fun ChatSheetContent(viewModel: MainViewModel) { onPickImages = { pickImages.launch("image/*") }, onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) }, - onSelectSession = { key -> viewModel.switchChatSession(key) }, onRefresh = { viewModel.refreshChat() viewModel.refreshChatSessions(limit = 200) @@ -133,6 +152,85 @@ fun ChatSheetContent(viewModel: MainViewModel) { } } +@Composable +private fun ChatThreadSelector( + sessionKey: String, + sessions: List, + mainSessionKey: String, + healthOk: Boolean, + onSelectSession: (String) -> Unit, +) { + val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) + val currentSessionLabel = + friendlySessionName(sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey) + + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Text( + text = "SESSION", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp), + color = mobileTextSecondary, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Text( + text = currentSessionLabel, + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = mobileText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + ChatConnectionPill(healthOk = healthOk) + } + } + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (entry in sessionOptions) { + val active = entry.key == sessionKey + Surface( + onClick = { onSelectSession(entry.key) }, + shape = RoundedCornerShape(14.dp), + color = if (active) mobileAccent else Color.White, + border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Text( + text = friendlySessionName(entry.displayName ?: entry.key), + style = mobileCaption1.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold), + color = if (active) Color.White else mobileText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + } + } + } +} + +@Composable +private fun ChatConnectionPill(healthOk: Boolean) { + Surface( + shape = RoundedCornerShape(999.dp), + color = if (healthOk) mobileSuccessSoft else mobileWarningSoft, + border = BorderStroke(1.dp, if (healthOk) mobileSuccess.copy(alpha = 0.35f) else mobileWarning.copy(alpha = 0.35f)), + ) { + Text( + text = if (healthOk) "Connected" else "Offline", + style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), + color = if (healthOk) mobileSuccess else mobileWarning, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} + @Composable private fun ChatErrorRail(errorText: String) { Surface( From 8b24830e0730cbc4339600e00c3dcc5eb0f6ad03 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 22:11:04 +0530 Subject: [PATCH 231/408] fix(android-gateway): avoid token clear on transient connect failure --- .../openclaw/android/gateway/GatewaySession.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt index 5550ec00664d..b7040d2ae271 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt @@ -307,16 +307,18 @@ class GatewaySession( val msg = res.error?.message ?: "connect failed" val hasStoredToken = !storedToken.isNullOrBlank() val canRetryWithShared = hasStoredToken && trimmedToken.isNotBlank() - if (hasStoredToken) { - deviceAuthStore.clearToken(identity.deviceId, options.role) - } if (canRetryWithShared) { val sharedPayload = buildConnectParams(identity, connectNonce, trimmedToken, password?.trim()) - res = request("connect", sharedPayload, timeoutMs = 8_000) - } - if (!res.ok) { - val retryMsg = res.error?.message ?: msg - throw IllegalStateException(retryMsg) + val sharedRes = request("connect", sharedPayload, timeoutMs = 8_000) + if (!sharedRes.ok) { + val retryMsg = sharedRes.error?.message ?: msg + throw IllegalStateException(retryMsg) + } + // Stored device token was bypassed successfully; clear stale token for future connects. + deviceAuthStore.clearToken(identity.deviceId, options.role) + res = sharedRes + } else { + throw IllegalStateException(msg) } } handleConnectSuccess(res, identity.deviceId) From 7a74cf34ba5f059f6b8de0eb520cd429b90f6fe9 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 22:11:07 +0530 Subject: [PATCH 232/408] fix(android-security): remove token-derived logging from prefs --- .../java/ai/openclaw/android/SecurePrefs.kt | 31 ++----------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt index 54f6292d29e2..f03e2b56e0b0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt @@ -4,7 +4,6 @@ package ai.openclaw.android import android.content.Context import android.content.SharedPreferences -import android.util.Log import androidx.core.content.edit import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey @@ -14,7 +13,6 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive -import java.security.MessageDigest import java.util.UUID class SecurePrefs(context: Context) { @@ -100,10 +98,6 @@ class SecurePrefs(context: Context) { private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false)) val talkEnabled: StateFlow = _talkEnabled - init { - logGatewayToken("init.gateway.manual.token", _gatewayToken.value) - } - fun setLastDiscoveredStableId(value: String) { val trimmed = value.trim() prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) } @@ -161,7 +155,6 @@ class SecurePrefs(context: Context) { val trimmed = value.trim() prefs.edit(commit = true) { putString("gateway.manual.token", trimmed) } _gatewayToken.value = trimmed - logGatewayToken("setGatewayToken", trimmed) } fun setGatewayPassword(value: String) { @@ -180,15 +173,10 @@ class SecurePrefs(context: Context) { fun loadGatewayToken(): String? { val manual = _gatewayToken.value.trim() - if (manual.isNotEmpty()) { - logGatewayToken("loadGatewayToken.manual", manual) - return manual - } + if (manual.isNotEmpty()) return manual val key = "gateway.token.${_instanceId.value}" val stored = prefs.getString(key, null)?.trim() - val resolved = stored?.takeIf { it.isNotEmpty() } - logGatewayToken("loadGatewayToken.legacy", resolved.orEmpty()) - return resolved + return stored?.takeIf { it.isNotEmpty() } } fun saveGatewayToken(token: String) { @@ -247,21 +235,6 @@ class SecurePrefs(context: Context) { return fresh } - private fun logGatewayToken(event: String, value: String) { - val digest = - if (value.isBlank()) { - "empty" - } else { - try { - val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) - bytes.take(4).joinToString("") { "%02x".format(it) } - } catch (_: Throwable) { - "hash_err" - } - } - Log.i("OpenClawSecurePrefs", "$event tokenLen=${value.length} tokenSha256Prefix=$digest") - } - private fun loadOrMigrateDisplayName(context: Context): String { val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty() if (existing.isNotEmpty() && existing != "Android Node") return existing From 8892a1cd45f7793e8a1945b657a210f13fdfbdb7 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 22:11:11 +0530 Subject: [PATCH 233/408] refactor(android-ui): unify gateway config resolution paths --- .../openclaw/android/ui/ConnectTabScreen.kt | 125 +----------------- .../android/ui/GatewayConfigResolver.kt | 115 ++++++++++++++++ .../ai/openclaw/android/ui/OnboardingFlow.kt | 91 ++----------- 3 files changed, 130 insertions(+), 201 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt index 24336849d507..9f7cf2211a16 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt @@ -1,6 +1,5 @@ package ai.openclaw.android.ui -import android.util.Base64 import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background @@ -47,37 +46,13 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.core.net.toUri import ai.openclaw.android.MainViewModel -import java.util.Locale -import org.json.JSONObject private enum class ConnectInputMode { SetupCode, Manual, } -private data class ParsedConnectGateway( - val host: String, - val port: Int, - val tls: Boolean, - val displayUrl: String, -) - -private data class ConnectSetupCodePayload( - val url: String, - val token: String?, - val password: String?, -) - -private data class ConnectConfig( - val host: String, - val port: Int, - val tls: Boolean, - val token: String, - val password: String, -) - @Composable fun ConnectTabScreen(viewModel: MainViewModel) { val statusText by viewModel.statusText.collectAsState() @@ -132,9 +107,9 @@ fun ConnectTabScreen(viewModel: MainViewModel) { ) } - val setupResolvedEndpoint = remember(setupCode) { decodeConnectSetupCode(setupCode)?.url?.let { parseConnectGateway(it)?.displayUrl } } + val setupResolvedEndpoint = remember(setupCode) { decodeGatewaySetupCode(setupCode)?.url?.let { parseGatewayEndpoint(it)?.displayUrl } } val manualResolvedEndpoint = remember(manualHostInput, manualPortInput, manualTlsInput) { - composeConnectManualGatewayUrl(manualHostInput, manualPortInput, manualTlsInput)?.let { parseConnectGateway(it)?.displayUrl } + composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput)?.let { parseGatewayEndpoint(it)?.displayUrl } } val activeEndpoint = @@ -195,14 +170,14 @@ fun ConnectTabScreen(viewModel: MainViewModel) { } val config = - resolveConnectConfig( - mode = inputMode, + resolveGatewayConnectConfig( + useSetupCode = inputMode == ConnectInputMode.SetupCode, setupCode = setupCode, manualHost = manualHostInput, manualPort = manualPortInput, manualTls = manualTlsInput, - token = gatewayToken, - password = passwordInput, + fallbackToken = gatewayToken, + fallbackPassword = passwordInput, ) if (config == null) { @@ -520,91 +495,3 @@ private fun outlinedColors() = unfocusedTextColor = mobileText, cursorColor = mobileAccent, ) - -private fun resolveConnectConfig( - mode: ConnectInputMode, - setupCode: String, - manualHost: String, - manualPort: String, - manualTls: Boolean, - token: String, - password: String, -): ConnectConfig? { - return if (mode == ConnectInputMode.SetupCode) { - val setup = decodeConnectSetupCode(setupCode) ?: return null - val parsed = parseConnectGateway(setup.url) ?: return null - ConnectConfig( - host = parsed.host, - port = parsed.port, - tls = parsed.tls, - token = setup.token ?: token.trim(), - password = setup.password ?: password.trim(), - ) - } else { - val manualUrl = composeConnectManualGatewayUrl(manualHost, manualPort, manualTls) ?: return null - val parsed = parseConnectGateway(manualUrl) ?: return null - ConnectConfig( - host = parsed.host, - port = parsed.port, - tls = parsed.tls, - token = token.trim(), - password = password.trim(), - ) - } -} - -private fun parseConnectGateway(rawInput: String): ParsedConnectGateway? { - val raw = rawInput.trim() - if (raw.isEmpty()) return null - - val normalized = if (raw.contains("://")) raw else "https://$raw" - val uri = normalized.toUri() - val host = uri.host?.trim().orEmpty() - if (host.isEmpty()) return null - - val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty() - val tls = - when (scheme) { - "ws", "http" -> false - "wss", "https" -> true - else -> true - } - val port = uri.port.takeIf { it in 1..65535 } ?: 18789 - val displayUrl = "${if (tls) "https" else "http"}://$host:$port" - - return ParsedConnectGateway(host = host, port = port, tls = tls, displayUrl = displayUrl) -} - -private fun decodeConnectSetupCode(rawInput: String): ConnectSetupCodePayload? { - val trimmed = rawInput.trim() - if (trimmed.isEmpty()) return null - - val padded = - trimmed - .replace('-', '+') - .replace('_', '/') - .let { normalized -> - val remainder = normalized.length % 4 - if (remainder == 0) normalized else normalized + "=".repeat(4 - remainder) - } - - return try { - val decoded = String(Base64.decode(padded, Base64.DEFAULT), Charsets.UTF_8) - val obj = JSONObject(decoded) - val url = obj.optString("url").trim() - if (url.isEmpty()) return null - val token = obj.optString("token").trim().ifEmpty { null } - val password = obj.optString("password").trim().ifEmpty { null } - ConnectSetupCodePayload(url = url, token = token, password = password) - } catch (_: Throwable) { - null - } -} - -private fun composeConnectManualGatewayUrl(hostInput: String, portInput: String, tls: Boolean): String? { - val host = hostInput.trim() - val port = portInput.trim().toIntOrNull() ?: return null - if (host.isEmpty() || port !in 1..65535) return null - val scheme = if (tls) "https" else "http" - return "$scheme://$host:$port" -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt new file mode 100644 index 000000000000..5036c6290d3f --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt @@ -0,0 +1,115 @@ +package ai.openclaw.android.ui + +import android.util.Base64 +import androidx.core.net.toUri +import java.util.Locale +import org.json.JSONObject + +internal data class GatewayEndpointConfig( + val host: String, + val port: Int, + val tls: Boolean, + val displayUrl: String, +) + +internal data class GatewaySetupCode( + val url: String, + val token: String?, + val password: String?, +) + +internal data class GatewayConnectConfig( + val host: String, + val port: Int, + val tls: Boolean, + val token: String, + val password: String, +) + +internal fun resolveGatewayConnectConfig( + useSetupCode: Boolean, + setupCode: String, + manualHost: String, + manualPort: String, + manualTls: Boolean, + fallbackToken: String, + fallbackPassword: String, +): GatewayConnectConfig? { + if (useSetupCode) { + val setup = decodeGatewaySetupCode(setupCode) ?: return null + val parsed = parseGatewayEndpoint(setup.url) ?: return null + return GatewayConnectConfig( + host = parsed.host, + port = parsed.port, + tls = parsed.tls, + token = setup.token ?: fallbackToken.trim(), + password = setup.password ?: fallbackPassword.trim(), + ) + } + + val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) ?: return null + val parsed = parseGatewayEndpoint(manualUrl) ?: return null + return GatewayConnectConfig( + host = parsed.host, + port = parsed.port, + tls = parsed.tls, + token = fallbackToken.trim(), + password = fallbackPassword.trim(), + ) +} + +internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? { + val raw = rawInput.trim() + if (raw.isEmpty()) return null + + val normalized = if (raw.contains("://")) raw else "https://$raw" + val uri = normalized.toUri() + val host = uri.host?.trim().orEmpty() + if (host.isEmpty()) return null + + val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty() + val tls = + when (scheme) { + "ws", "http" -> false + "wss", "https" -> true + else -> true + } + val port = uri.port.takeIf { it in 1..65535 } ?: 18789 + val displayUrl = "${if (tls) "https" else "http"}://$host:$port" + + return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl) +} + +internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? { + val trimmed = rawInput.trim() + if (trimmed.isEmpty()) return null + + val padded = + trimmed + .replace('-', '+') + .replace('_', '/') + .let { normalized -> + val remainder = normalized.length % 4 + if (remainder == 0) normalized else normalized + "=".repeat(4 - remainder) + } + + return try { + val decoded = String(Base64.decode(padded, Base64.DEFAULT), Charsets.UTF_8) + val obj = JSONObject(decoded) + val url = obj.optString("url").trim() + if (url.isEmpty()) return null + val token = obj.optString("token").trim().ifEmpty { null } + val password = obj.optString("password").trim().ifEmpty { null } + GatewaySetupCode(url = url, token = token, password = password) + } catch (_: Throwable) { + null + } +} + +internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls: Boolean): String? { + val host = hostInput.trim() + val port = portInput.trim().toIntOrNull() ?: return null + if (host.isEmpty() || port !in 1..65535) return null + val scheme = if (tls) "https" else "http" + return "$scheme://$host:$port" +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt index 780781455be9..8c732d9c3603 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt @@ -4,7 +4,6 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.os.Build -import android.util.Base64 import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -72,12 +71,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat -import androidx.core.net.toUri import ai.openclaw.android.LocationMode import ai.openclaw.android.MainViewModel import ai.openclaw.android.R -import java.util.Locale -import org.json.JSONObject private enum class OnboardingStep(val index: Int, val label: String) { Welcome(1, "Welcome"), @@ -91,19 +87,6 @@ private enum class GatewayInputMode { Manual, } -private data class ParsedGateway( - val host: String, - val port: Int, - val tls: Boolean, - val displayUrl: String, -) - -private data class SetupCodePayload( - val url: String, - val token: String?, - val password: String?, -) - private val onboardingBackgroundGradient = listOf( Color(0xFFFFFFFF), @@ -377,7 +360,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { ) OnboardingStep.FinalCheck -> FinalStep( - parsedGateway = parseGateway(gatewayUrl), + parsedGateway = parseGatewayEndpoint(gatewayUrl), statusText = statusText, isConnected = isConnected, serverName = serverName, @@ -444,12 +427,12 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { Button( onClick = { if (gatewayInputMode == GatewayInputMode.SetupCode) { - val parsedSetup = decodeSetupCode(setupCode) + val parsedSetup = decodeGatewaySetupCode(setupCode) if (parsedSetup == null) { gatewayError = "Invalid setup code." return@Button } - val parsedGateway = parseGateway(parsedSetup.url) + val parsedGateway = parseGatewayEndpoint(parsedSetup.url) if (parsedGateway == null) { gatewayError = "Setup code has invalid gateway URL." return@Button @@ -458,8 +441,8 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { parsedSetup.token?.let { viewModel.setGatewayToken(it) } gatewayPassword = parsedSetup.password.orEmpty() } else { - val manualUrl = composeManualGatewayUrl(manualHost, manualPort, manualTls) - val parsedGateway = manualUrl?.let(::parseGateway) + val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) + val parsedGateway = manualUrl?.let(::parseGatewayEndpoint) if (parsedGateway == null) { gatewayError = "Manual endpoint is invalid." return@Button @@ -524,7 +507,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { } else { Button( onClick = { - val parsed = parseGateway(gatewayUrl) + val parsed = parseGatewayEndpoint(gatewayUrl) if (parsed == null) { step = OnboardingStep.Gateway gatewayError = "Invalid gateway URL." @@ -639,8 +622,8 @@ private fun GatewayStep( onTokenChange: (String) -> Unit, onPasswordChange: (String) -> Unit, ) { - val resolvedEndpoint = remember(setupCode) { decodeSetupCode(setupCode)?.url?.let { parseGateway(it)?.displayUrl } } - val manualResolvedEndpoint = remember(manualHost, manualPort, manualTls) { composeManualGatewayUrl(manualHost, manualPort, manualTls)?.let { parseGateway(it)?.displayUrl } } + val resolvedEndpoint = remember(setupCode) { decodeGatewaySetupCode(setupCode)?.url?.let { parseGatewayEndpoint(it)?.displayUrl } } + val manualResolvedEndpoint = remember(manualHost, manualPort, manualTls) { composeGatewayManualUrl(manualHost, manualPort, manualTls)?.let { parseGatewayEndpoint(it)?.displayUrl } } StepShell(title = "Gateway Connection") { GuideBlock(title = "Get setup code + gateway URL") { @@ -1046,7 +1029,7 @@ private fun PermissionToggleRow( @Composable private fun FinalStep( - parsedGateway: ParsedGateway?, + parsedGateway: GatewayEndpointConfig?, statusText: String, isConnected: Boolean, serverName: String?, @@ -1132,62 +1115,6 @@ private fun Bullet(text: String) { } } -private fun parseGateway(rawInput: String): ParsedGateway? { - val raw = rawInput.trim() - if (raw.isEmpty()) return null - - val normalized = if (raw.contains("://")) raw else "https://$raw" - val uri = normalized.toUri() - val host = uri.host?.trim().orEmpty() - if (host.isEmpty()) return null - - val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty() - val tls = - when (scheme) { - "ws", "http" -> false - "wss", "https" -> true - else -> true - } - val port = uri.port.takeIf { it in 1..65535 } ?: 18789 - val displayUrl = "${if (tls) "https" else "http"}://$host:$port" - - return ParsedGateway(host = host, port = port, tls = tls, displayUrl = displayUrl) -} - -private fun decodeSetupCode(rawInput: String): SetupCodePayload? { - val trimmed = rawInput.trim() - if (trimmed.isEmpty()) return null - - val padded = - trimmed - .replace('-', '+') - .replace('_', '/') - .let { normalized -> - val remainder = normalized.length % 4 - if (remainder == 0) normalized else normalized + "=".repeat(4 - remainder) - } - - return try { - val decoded = String(Base64.decode(padded, Base64.DEFAULT), Charsets.UTF_8) - val obj = JSONObject(decoded) - val url = obj.optString("url").trim() - if (url.isEmpty()) return null - val token = obj.optString("token").trim().ifEmpty { null } - val password = obj.optString("password").trim().ifEmpty { null } - SetupCodePayload(url = url, token = token, password = password) - } catch (_: Throwable) { - null - } -} - private fun isPermissionGranted(context: Context, permission: String): Boolean { return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED } - -private fun composeManualGatewayUrl(hostInput: String, portInput: String, tls: Boolean): String? { - val host = hostInput.trim() - val port = portInput.trim().toIntOrNull() ?: return null - if (host.isEmpty() || port !in 1..65535) return null - val scheme = if (tls) "https" else "http" - return "$scheme://$host:$port" -} From 00de3ca8338aedf91869c7e3f879d1d74293d366 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 22:15:42 +0530 Subject: [PATCH 234/408] fix: widen external link rel token set type --- ui/src/ui/external-link.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/ui/external-link.ts b/ui/src/ui/external-link.ts index fc7ff5ef7e2e..0922da638d02 100644 --- a/ui/src/ui/external-link.ts +++ b/ui/src/ui/external-link.ts @@ -4,7 +4,7 @@ export const EXTERNAL_LINK_TARGET = "_blank"; export function buildExternalLinkRel(currentRel?: string): string { const extraTokens: string[] = []; - const seen = new Set(REQUIRED_EXTERNAL_REL_TOKENS); + const seen = new Set(REQUIRED_EXTERNAL_REL_TOKENS); for (const rawToken of (currentRel ?? "").split(/\s+/)) { const token = rawToken.trim().toLowerCase(); From 51b3e23680da96b6243a610c66a4cb5850ce7c72 Mon Sep 17 00:00:00 2001 From: Glucksberg Date: Sun, 15 Feb 2026 00:45:49 +0000 Subject: [PATCH 235/408] fix(telegram): fallback to plain text when threaded markdown renders empty Minimal fix path for Telegram empty-text failures in threaded replies. - fallback to plain text when formatted htmlText is empty - retry plain text on parse/empty-text API errors - add focused regression test for threaded mode case Related: #25091 Supersedes alternative fix path in #17629 if maintainers prefer minimal scope. --- src/telegram/bot/delivery.test.ts | 34 ++++++++++++++++++++++++ src/telegram/bot/delivery.ts | 43 ++++++++++++++++++++----------- 2 files changed, 62 insertions(+), 15 deletions(-) diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index d4606ac14147..27b365b3b1a1 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -244,6 +244,40 @@ describe("deliverReplies", () => { ); }); + it("falls back to plain text when markdown renders to empty HTML in threaded mode", async () => { + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendMessage = vi.fn(async (_chatId: string, text: string) => { + if (text === "") { + throw new Error("400: Bad Request: message text is empty"); + } + return { + message_id: 6, + chat: { id: "123" }, + }; + }); + const bot = { api: { sendMessage } } as unknown as Bot; + + await deliverReplies({ + replies: [{ text: ">" }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4000, + thread: { id: 42, scope: "forum" }, + }); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith( + "123", + ">", + expect.objectContaining({ + message_thread_id: 42, + }), + ); + }); + it("uses reply_to_message_id when quote text is provided", async () => { const runtime = createRuntime(); const sendMessage = vi.fn().mockResolvedValue({ diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 5e0cfb2ea1f3..e515c686d882 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -41,6 +41,7 @@ const TELEGRAM_MEDIA_SSRF_POLICY = { allowedHostnames: ["api.telegram.org"], allowRfc2544BenchmarkRange: true, }; +const EMPTY_TEXT_ERR_RE = /message text is empty/i; export async function deliverReplies(params: { replies: ReplyPayload[]; @@ -553,6 +554,30 @@ async function sendTelegramText( const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true }; const textMode = opts?.textMode ?? "markdown"; const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); + const fallbackText = opts?.plainText ?? text; + const hasFallbackText = fallbackText.trim().length > 0; + const sendPlainFallback = async () => { + if (!hasFallbackText) { + return undefined; + } + const res = await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime, + fn: () => + bot.api.sendMessage(chatId, fallbackText, { + ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), + ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), + ...baseParams, + }), + }); + return res.message_id; + }; + + // Markdown can occasionally render to empty HTML (for example syntax-only chunks). + // Telegram rejects those sends, so fall back to plain text early. + if (!htmlText.trim()) { + return await sendPlainFallback(); + } try { const res = await withTelegramApiErrorLogging({ operation: "sendMessage", @@ -570,21 +595,9 @@ async function sendTelegramText( return res.message_id; } catch (err) { const errText = formatErrorMessage(err); - if (PARSE_ERR_RE.test(errText)) { - runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`); - const fallbackText = opts?.plainText ?? text; - const res = await withTelegramApiErrorLogging({ - operation: "sendMessage", - runtime, - fn: () => - bot.api.sendMessage(chatId, fallbackText, { - ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), - ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), - ...baseParams, - }), - }); - runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id} (plain)`); - return res.message_id; + if (PARSE_ERR_RE.test(errText) || EMPTY_TEXT_ERR_RE.test(errText)) { + runtime.log?.(`telegram formatted send failed; retrying without formatting: ${errText}`); + return await sendPlainFallback(); } throw err; } From 566a8e7137d32884c45e87d1dd354e26a5d4c944 Mon Sep 17 00:00:00 2001 From: Glucksberg Date: Tue, 24 Feb 2026 05:49:19 +0000 Subject: [PATCH 236/408] chore(telegram): suppress handled empty-text retry logs --- src/telegram/bot/delivery.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index e515c686d882..937c8483ec1f 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -582,7 +582,10 @@ async function sendTelegramText( const res = await withTelegramApiErrorLogging({ operation: "sendMessage", runtime, - shouldLog: (err) => !PARSE_ERR_RE.test(formatErrorMessage(err)), + shouldLog: (err) => { + const errText = formatErrorMessage(err); + return !PARSE_ERR_RE.test(errText) && !EMPTY_TEXT_ERR_RE.test(errText); + }, fn: () => bot.api.sendMessage(chatId, htmlText, { parse_mode: "HTML", From 6e31bca1987c47f8d8073943c3173884006a2728 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 22:23:17 +0530 Subject: [PATCH 237/408] fix(telegram): fail loud on empty text fallback --- src/telegram/bot/delivery.test.ts | 19 +++++++++++++++++++ src/telegram/bot/delivery.ts | 17 ++++++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index 27b365b3b1a1..846f5b409dba 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -278,6 +278,25 @@ describe("deliverReplies", () => { ); }); + it("throws when formatted and plain fallback text are both empty", async () => { + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendMessage = vi.fn(); + const bot = { api: { sendMessage } } as unknown as Bot; + + await expect( + deliverReplies({ + replies: [{ text: " " }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4000, + }), + ).rejects.toThrow("empty formatted text and empty plain fallback"); + expect(sendMessage).not.toHaveBeenCalled(); + }); + it("uses reply_to_message_id when quote text is provided", async () => { const runtime = createRuntime(); const sendMessage = vi.fn().mockResolvedValue({ diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 937c8483ec1f..748fca00a4d0 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -33,6 +33,7 @@ import { import type { StickerMetadata, TelegramContext } from "./types.js"; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; +const EMPTY_TEXT_ERR_RE = /message text is empty/i; const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; const FILE_TOO_BIG_RE = /file is too big/i; const TELEGRAM_MEDIA_SSRF_POLICY = { @@ -41,7 +42,6 @@ const TELEGRAM_MEDIA_SSRF_POLICY = { allowedHostnames: ["api.telegram.org"], allowRfc2544BenchmarkRange: true, }; -const EMPTY_TEXT_ERR_RE = /message text is empty/i; export async function deliverReplies(params: { replies: ReplyPayload[]; @@ -544,7 +544,7 @@ async function sendTelegramText( linkPreview?: boolean; replyMarkup?: ReturnType; }, -): Promise { +): Promise { const baseParams = buildTelegramSendParams({ replyToMessageId: opts?.replyToMessageId, thread: opts?.thread, @@ -557,9 +557,6 @@ async function sendTelegramText( const fallbackText = opts?.plainText ?? text; const hasFallbackText = fallbackText.trim().length > 0; const sendPlainFallback = async () => { - if (!hasFallbackText) { - return undefined; - } const res = await withTelegramApiErrorLogging({ operation: "sendMessage", runtime, @@ -570,12 +567,15 @@ async function sendTelegramText( ...baseParams, }), }); + runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id} (plain)`); return res.message_id; }; - // Markdown can occasionally render to empty HTML (for example syntax-only chunks). - // Telegram rejects those sends, so fall back to plain text early. + // Markdown can render to empty HTML for syntax-only chunks; recover with plain text. if (!htmlText.trim()) { + if (!hasFallbackText) { + throw new Error("telegram sendMessage failed: empty formatted text and empty plain fallback"); + } return await sendPlainFallback(); } try { @@ -599,6 +599,9 @@ async function sendTelegramText( } catch (err) { const errText = formatErrorMessage(err); if (PARSE_ERR_RE.test(errText) || EMPTY_TEXT_ERR_RE.test(errText)) { + if (!hasFallbackText) { + throw err; + } runtime.log?.(`telegram formatted send failed; retrying without formatting: ${errText}`); return await sendPlainFallback(); } From f154926cc0dc52898b23d183a1b500a0f5bf1f5c Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 24 Feb 2026 22:33:08 +0530 Subject: [PATCH 238/408] fix: land telegram empty-html fallback hardening (#25096) (thanks @Glucksberg) --- CHANGELOG.md | 1 + src/telegram/bot/delivery.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25dfd5361670..e44e3d32b9c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting. - Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. +- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. - Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. - Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. - Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index 846f5b409dba..971ee679c261 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -245,7 +245,7 @@ describe("deliverReplies", () => { }); it("falls back to plain text when markdown renders to empty HTML in threaded mode", async () => { - const runtime = { error: vi.fn(), log: vi.fn() }; + const runtime = createRuntime(); const sendMessage = vi.fn(async (_chatId: string, text: string) => { if (text === "") { throw new Error("400: Bad Request: message text is empty"); @@ -279,7 +279,7 @@ describe("deliverReplies", () => { }); it("throws when formatted and plain fallback text are both empty", async () => { - const runtime = { error: vi.fn(), log: vi.fn() }; + const runtime = createRuntime(); const sendMessage = vi.fn(); const bot = { api: { sendMessage } } as unknown as Bot; From 9ef0fc2ff8fa7b145d1e746d6eb030b1bf692260 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 17:22:46 +0000 Subject: [PATCH 239/408] fix(sandbox): block @-prefixed workspace path bypass --- CHANGELOG.md | 1 + src/agents/pi-tools.read.ts | 2 +- ...pi-tools.read.workspace-root-guard.test.ts | 30 +++++++++++++++++++ src/agents/pi-tools.workspace-paths.test.ts | 14 +++++++++ src/agents/sandbox-paths.ts | 6 +++- src/agents/sandbox/fs-paths.ts | 8 ++++- 6 files changed, 58 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e44e3d32b9c4..5a52abd7ffd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting. - Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index a5fb9a1ccd08..4fe53c3317c0 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -571,7 +571,7 @@ function mapContainerPathToWorkspaceRoot(params: { return params.filePath; } - let candidate = params.filePath; + let candidate = params.filePath.startsWith("@") ? params.filePath.slice(1) : params.filePath; if (/^file:\/\//i.test(candidate)) { try { candidate = fileURLToPath(candidate); diff --git a/src/agents/pi-tools.read.workspace-root-guard.test.ts b/src/agents/pi-tools.read.workspace-root-guard.test.ts index 0e6f76109f61..3757e7a1f4bd 100644 --- a/src/agents/pi-tools.read.workspace-root-guard.test.ts +++ b/src/agents/pi-tools.read.workspace-root-guard.test.ts @@ -61,6 +61,36 @@ describe("wrapToolWorkspaceRootGuardWithOptions", () => { }); }); + it("maps @-prefixed container workspace paths to host workspace root", async () => { + const { tool } = createToolHarness(); + const wrapped = wrapToolWorkspaceRootGuardWithOptions(tool, root, { + containerWorkdir: "/workspace", + }); + + await wrapped.execute("tc-at-container", { path: "@/workspace/docs/readme.md" }); + + expect(mocks.assertSandboxPath).toHaveBeenCalledWith({ + filePath: path.resolve(root, "docs", "readme.md"), + cwd: root, + root, + }); + }); + + it("normalizes @-prefixed absolute paths before guard checks", async () => { + const { tool } = createToolHarness(); + const wrapped = wrapToolWorkspaceRootGuardWithOptions(tool, root, { + containerWorkdir: "/workspace", + }); + + await wrapped.execute("tc-at-absolute", { path: "@/etc/passwd" }); + + expect(mocks.assertSandboxPath).toHaveBeenCalledWith({ + filePath: "/etc/passwd", + cwd: root, + root, + }); + }); + it("does not remap absolute paths outside the configured container workdir", async () => { const { tool } = createToolHarness(); const wrapped = wrapToolWorkspaceRootGuardWithOptions(tool, root, { diff --git a/src/agents/pi-tools.workspace-paths.test.ts b/src/agents/pi-tools.workspace-paths.test.ts index 969bc448caf4..6fe98ff03f8f 100644 --- a/src/agents/pi-tools.workspace-paths.test.ts +++ b/src/agents/pi-tools.workspace-paths.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; import { createOpenClawCodingTools } from "./pi-tools.js"; import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js"; import { expectReadWriteEditTools, getTextContent } from "./test-helpers/pi-tools-fs-helpers.js"; @@ -137,6 +138,19 @@ describe("workspace path resolution", () => { }); }); }); + + it("rejects @-prefixed absolute paths outside workspace when workspaceOnly is enabled", async () => { + await withTempDir("openclaw-ws-", async (workspaceDir) => { + const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } }; + const tools = createOpenClawCodingTools({ workspaceDir, config: cfg }); + const { readTool } = expectReadWriteEditTools(tools); + + const outsideAbsolute = path.resolve(path.parse(workspaceDir).root, "outside-openclaw.txt"); + await expect( + readTool.execute("ws-read-at-prefix", { path: `@${outsideAbsolute}` }), + ).rejects.toThrow(/Path escapes sandbox root/i); + }); + }); }); describe("sandboxed workspace paths", () => { diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index 31203715f99a..5b684bbe4252 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -13,8 +13,12 @@ function normalizeUnicodeSpaces(str: string): string { return str.replace(UNICODE_SPACES, " "); } +function normalizeAtPrefix(filePath: string): string { + return filePath.startsWith("@") ? filePath.slice(1) : filePath; +} + function expandPath(filePath: string): string { - const normalized = normalizeUnicodeSpaces(filePath); + const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath)); if (normalized === "~") { return os.homedir(); } diff --git a/src/agents/sandbox/fs-paths.ts b/src/agents/sandbox/fs-paths.ts index 5de2f9e12eeb..3073c6e84580 100644 --- a/src/agents/sandbox/fs-paths.ts +++ b/src/agents/sandbox/fs-paths.ts @@ -227,7 +227,13 @@ function isPathInsidePosix(root: string, target: string): boolean { function isPathInsideHost(root: string, target: string): boolean { const canonicalRoot = resolveSandboxHostPathViaExistingAncestor(path.resolve(root)); - const canonicalTarget = resolveSandboxHostPathViaExistingAncestor(path.resolve(target)); + const resolvedTarget = path.resolve(target); + // Preserve the final path segment so pre-existing symlink leaves are validated + // by the dedicated symlink guard later in the bridge flow. + const canonicalTargetParent = resolveSandboxHostPathViaExistingAncestor( + path.dirname(resolvedTarget), + ); + const canonicalTarget = path.resolve(canonicalTargetParent, path.basename(resolvedTarget)); const rel = path.relative(canonicalRoot, canonicalTarget); if (!rel) { return true; From e9750104b2418bcb2eec7b126b115cae2c1f9c89 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 24 Feb 2026 14:50:04 +0000 Subject: [PATCH 240/408] ui: block svg data image opens and harden tests --- .github/workflows/ci.yml | 3 ++ ui/src/ui/open-external-url.test.ts | 44 ++++++++++++++++++- ui/src/ui/open-external-url.ts | 7 ++- .../ui/views/chat-image-open.browser.test.ts | 17 +++++++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0266c721748..8de4f3882c8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -259,6 +259,9 @@ jobs: - name: Check types and lint and oxfmt run: pnpm check + - name: Enforce safe external URL opening policy + run: pnpm lint:ui:no-raw-window-open + # Report-only dead-code scans. Runs after scope detection and stores machine-readable # results as artifacts for later triage before we enable hard gates. # Temporarily disabled in CI while we process initial findings. diff --git a/ui/src/ui/open-external-url.test.ts b/ui/src/ui/open-external-url.test.ts index 8972516ed57e..fb3f15d88d9d 100644 --- a/ui/src/ui/open-external-url.test.ts +++ b/ui/src/ui/open-external-url.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it } from "vitest"; -import { resolveSafeExternalUrl } from "./open-external-url.ts"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { openExternalUrlSafe, resolveSafeExternalUrl } from "./open-external-url.ts"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); describe("resolveSafeExternalUrl", () => { const baseHref = "https://openclaw.ai/chat"; @@ -38,6 +43,18 @@ describe("resolveSafeExternalUrl", () => { ).toBeNull(); }); + it("rejects SVG data image URLs", () => { + expect( + resolveSafeExternalUrl( + "data:image/svg+xml,", + baseHref, + { + allowDataImage: true, + }, + ), + ).toBeNull(); + }); + it("rejects data image URLs unless explicitly enabled", () => { expect(resolveSafeExternalUrl("data:image/png;base64,iVBORw0KGgo=", baseHref)).toBeNull(); }); @@ -54,3 +71,26 @@ describe("resolveSafeExternalUrl", () => { expect(resolveSafeExternalUrl(" ", baseHref)).toBeNull(); }); }); + +describe("openExternalUrlSafe", () => { + it("nulls opener when window.open returns a proxy-like object", () => { + const openedLikeProxy = { + opener: { postMessage: () => void 0 }, + } as unknown as WindowProxy; + const openMock = vi.fn(() => openedLikeProxy); + vi.stubGlobal("window", { + location: { href: "https://openclaw.ai/chat" }, + open: openMock, + } as unknown as Window & typeof globalThis); + + const opened = openExternalUrlSafe("https://example.com/safe.png"); + + expect(openMock).toHaveBeenCalledWith( + "https://example.com/safe.png", + "_blank", + "noopener,noreferrer", + ); + expect(opened).toBe(openedLikeProxy); + expect(openedLikeProxy.opener).toBeNull(); + }); +}); diff --git a/ui/src/ui/open-external-url.ts b/ui/src/ui/open-external-url.ts index 321e69a71fcd..ed5a99c86786 100644 --- a/ui/src/ui/open-external-url.ts +++ b/ui/src/ui/open-external-url.ts @@ -1,5 +1,6 @@ const DATA_URL_PREFIX = "data:"; const ALLOWED_EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "blob:"]); +const BLOCKED_DATA_IMAGE_MIME_TYPES = new Set(["image/svg+xml"]); function isAllowedDataImageUrl(url: string): boolean { if (!url.toLowerCase().startsWith(DATA_URL_PREFIX)) { @@ -13,7 +14,11 @@ function isAllowedDataImageUrl(url: string): boolean { const metadata = url.slice(DATA_URL_PREFIX.length, commaIndex); const mimeType = metadata.split(";")[0]?.trim().toLowerCase() ?? ""; - return mimeType.startsWith("image/"); + if (!mimeType.startsWith("image/")) { + return false; + } + + return !BLOCKED_DATA_IMAGE_MIME_TYPES.has(mimeType); } export type ResolveSafeExternalUrlOptions = { diff --git a/ui/src/ui/views/chat-image-open.browser.test.ts b/ui/src/ui/views/chat-image-open.browser.test.ts index 60e6df26554a..9f2090a139b9 100644 --- a/ui/src/ui/views/chat-image-open.browser.test.ts +++ b/ui/src/ui/views/chat-image-open.browser.test.ts @@ -50,4 +50,21 @@ describe("chat image open safety", () => { expect(openSpy).not.toHaveBeenCalled(); }); + + it("does not open SVG data image URLs", async () => { + const app = mountApp("/chat"); + await app.updateComplete; + + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + app.chatMessages = [ + renderAssistantImage("data:image/svg+xml,"), + ]; + await app.updateComplete; + + const image = app.querySelector(".chat-message-image"); + expect(image).not.toBeNull(); + image?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(openSpy).not.toHaveBeenCalled(); + }); }); From e7298b844f7e19646280c5e03ce04a3c59136f24 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 24 Feb 2026 21:45:53 +0000 Subject: [PATCH 241/408] changelog: credit both chat-image fix contributors --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a52abd7ffd7..9fe1743ae57e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ Docs: https://docs.openclaw.ai - Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. - iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach. - macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. -- Control UI/Chat images: centralize safe external URL opening for image clicks (allowlist `http/https/blob` + opt-in `data:image/*`) and enforce opener isolation (`noopener,noreferrer` + `window.opener = null`) to prevent tabnabbing/unsafe schemes. (#25444) Thanks @shakkernerd. +- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444) Thanks @Mariana-Codebase and @shakkernerd. - CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. - Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey. - Doctor/Plugins: auto-enable now resolves third-party channel plugins by manifest plugin id (not channel id), preventing invalid `plugins.entries.` writes when ids differ. (#25275) Thanks @zerone0x. From 30cb849b10608cc02ad748b36a6ed5deff08d5fb Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 24 Feb 2026 22:00:56 +0000 Subject: [PATCH 242/408] test(ui): reject base64 SVG data URLs --- ui/src/ui/open-external-url.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ui/src/ui/open-external-url.test.ts b/ui/src/ui/open-external-url.test.ts index fb3f15d88d9d..d79ef099bd44 100644 --- a/ui/src/ui/open-external-url.test.ts +++ b/ui/src/ui/open-external-url.test.ts @@ -55,6 +55,18 @@ describe("resolveSafeExternalUrl", () => { ).toBeNull(); }); + it("rejects base64-encoded SVG data image URLs", () => { + expect( + resolveSafeExternalUrl( + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIC8+", + baseHref, + { + allowDataImage: true, + }, + ), + ).toBeNull(); + }); + it("rejects data image URLs unless explicitly enabled", () => { expect(resolveSafeExternalUrl("data:image/png;base64,iVBORw0KGgo=", baseHref)).toBeNull(); }); From 853f75592f5bf1ef47ed2cb91e50f2aa8926820d Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 24 Feb 2026 22:17:34 +0000 Subject: [PATCH 243/408] changelog: include #25847 in chat image safety entry (#25847) (thanks @shakkernerd) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe1743ae57e..0fb89d49e0eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ Docs: https://docs.openclaw.ai - Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. - iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach. - macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. -- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444) Thanks @Mariana-Codebase and @shakkernerd. +- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd. - CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. - Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey. - Doctor/Plugins: auto-enable now resolves third-party channel plugins by manifest plugin id (not channel id), preventing invalid `plugins.entries.` writes when ids differ. (#25275) Thanks @zerone0x. From f4e6f873033db95bed92555920734107d54c6ee6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 22:38:47 +0000 Subject: [PATCH 244/408] refactor(ios): drop legacy talk payload and keychain fallbacks --- .../Gateway/GatewaySettingsStore.swift | 27 ------------ apps/ios/Sources/Voice/TalkModeManager.swift | 44 +++++++++---------- .../ios/Tests/GatewaySettingsStoreTests.swift | 21 --------- .../Tests/TalkModeConfigParsingTests.swift | 15 +++---- 4 files changed, 26 insertions(+), 81 deletions(-) diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index 264aa8aa50d9..49db9bb1bfc6 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -26,7 +26,6 @@ enum GatewaySettingsStore { private static let preferredGatewayStableIDAccount = "preferredStableID" private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID" private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." - private static let talkElevenLabsApiKeyLegacyAccount = "elevenlabs.apiKey" static func bootstrapPersistence() { self.ensureStableInstanceID() @@ -154,18 +153,6 @@ enum GatewaySettingsStore { account: account)? .trimmingCharacters(in: .whitespacesAndNewlines) if value?.isEmpty == false { return value } - - if providerId == "elevenlabs" { - let legacyValue = KeychainStore.loadString( - service: self.talkService, - account: self.talkElevenLabsApiKeyLegacyAccount)? - .trimmingCharacters(in: .whitespacesAndNewlines) - if legacyValue?.isEmpty == false { - _ = KeychainStore.saveString(legacyValue!, service: self.talkService, account: account) - return legacyValue - } - } - return nil } @@ -175,23 +162,9 @@ enum GatewaySettingsStore { let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmed.isEmpty { _ = KeychainStore.delete(service: self.talkService, account: account) - if providerId == "elevenlabs" { - _ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyLegacyAccount) - } return } _ = KeychainStore.saveString(trimmed, service: self.talkService, account: account) - if providerId == "elevenlabs" { - _ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyLegacyAccount) - } - } - - static func loadTalkElevenLabsApiKey() -> String? { - self.loadTalkProviderApiKey(provider: "elevenlabs") - } - - static func saveTalkElevenLabsApiKey(_ apiKey: String?) { - self.saveTalkProviderApiKey(apiKey, provider: "elevenlabs") } static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) { diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 4e1a67945f1b..d0ae9bc5cb2b 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -1889,7 +1889,6 @@ extension TalkModeManager { struct TalkProviderConfigSelection { let provider: String let config: [String: Any] - let normalizedPayload: Bool } private static func normalizedTalkProviderID(_ raw: String?) -> String? { @@ -1901,29 +1900,22 @@ extension TalkModeManager { guard let talk else { return nil } let rawProvider = talk["provider"] as? String let rawProviders = talk["providers"] as? [String: Any] - let hasNormalized = rawProvider != nil || rawProviders != nil - if hasNormalized { - let providers = rawProviders ?? [:] - let normalizedProviders = providers.reduce(into: [String: [String: Any]]()) { acc, entry in - guard - let providerID = Self.normalizedTalkProviderID(entry.key), - let config = entry.value as? [String: Any] - else { return } - acc[providerID] = config - } - let providerID = - Self.normalizedTalkProviderID(rawProvider) ?? - normalizedProviders.keys.sorted().first ?? - Self.defaultTalkProvider - return TalkProviderConfigSelection( - provider: providerID, - config: normalizedProviders[providerID] ?? [:], - normalizedPayload: true) - } + guard rawProvider != nil || rawProviders != nil else { return nil } + let providers = rawProviders ?? [:] + let normalizedProviders = providers.reduce(into: [String: [String: Any]]()) { acc, entry in + guard + let providerID = Self.normalizedTalkProviderID(entry.key), + let config = entry.value as? [String: Any] + else { return } + acc[providerID] = config + } + let providerID = + Self.normalizedTalkProviderID(rawProvider) ?? + normalizedProviders.keys.sorted().first ?? + Self.defaultTalkProvider return TalkProviderConfigSelection( - provider: Self.defaultTalkProvider, - config: talk, - normalizedPayload: false) + provider: providerID, + config: normalizedProviders[providerID] ?? [:]) } func reloadConfig() async { @@ -1934,6 +1926,10 @@ extension TalkModeManager { guard let config = json["config"] as? [String: Any] else { return } let talk = config["talk"] as? [String: Any] let selection = Self.selectTalkProviderConfig(talk) + if talk != nil, selection == nil { + GatewayDiagnostics.log( + "talk config ignored: legacy payload unsupported on iOS beta; expected talk.provider/providers") + } let activeProvider = selection?.provider ?? Self.defaultTalkProvider let activeConfig = selection?.config self.defaultVoiceId = (activeConfig?["voiceId"] as? String)? @@ -1983,7 +1979,7 @@ extension TalkModeManager { if let interrupt = talk?["interruptOnSpeech"] as? Bool { self.interruptOnSpeech = interrupt } - if selection?.normalizedPayload == true { + if selection != nil { GatewayDiagnostics.log("talk config provider=\(activeProvider)") } } catch { diff --git a/apps/ios/Tests/GatewaySettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift index ec879b3a0f30..0bac40152361 100644 --- a/apps/ios/Tests/GatewaySettingsStoreTests.swift +++ b/apps/ios/Tests/GatewaySettingsStoreTests.swift @@ -13,10 +13,6 @@ private let talkService = "ai.openclaw.talk" private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId") private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID") private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID") -private let talkElevenLabsLegacyEntry = KeychainEntry(service: talkService, account: "elevenlabs.apiKey") -private let talkElevenLabsProviderEntry = KeychainEntry( - service: talkService, - account: "provider.apiKey.elevenlabs") private let talkAcmeProviderEntry = KeychainEntry(service: talkService, account: "provider.apiKey.acme") private func snapshotDefaults(_ keys: [String]) -> [String: Any?] { @@ -215,21 +211,4 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) { GatewaySettingsStore.saveTalkProviderApiKey(nil, provider: "acme") #expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == nil) } - - @Test func talkProviderApiKey_elevenlabsLegacyFallbackMigratesToProviderKey() { - let keychainSnapshot = snapshotKeychain([talkElevenLabsLegacyEntry, talkElevenLabsProviderEntry]) - defer { restoreKeychain(keychainSnapshot) } - - _ = KeychainStore.delete(service: talkService, account: talkElevenLabsProviderEntry.account) - _ = KeychainStore.saveString( - "legacy-eleven-key", - service: talkService, - account: talkElevenLabsLegacyEntry.account) - - let loaded = GatewaySettingsStore.loadTalkProviderApiKey(provider: "elevenlabs") - #expect(loaded == "legacy-eleven-key") - #expect( - KeychainStore.loadString(service: talkService, account: talkElevenLabsProviderEntry.account) - == "legacy-eleven-key") - } } diff --git a/apps/ios/Tests/TalkModeConfigParsingTests.swift b/apps/ios/Tests/TalkModeConfigParsingTests.swift index fd5c3d0f3923..fd6b535f8a3b 100644 --- a/apps/ios/Tests/TalkModeConfigParsingTests.swift +++ b/apps/ios/Tests/TalkModeConfigParsingTests.swift @@ -1,8 +1,9 @@ import Testing @testable import OpenClaw +@MainActor @Suite struct TalkModeConfigParsingTests { - @Test func prefersNormalizedTalkProviderPayload() async { + @Test func prefersNormalizedTalkProviderPayload() { let talk: [String: Any] = [ "provider": "elevenlabs", "providers": [ @@ -13,22 +14,18 @@ import Testing "voiceId": "voice-legacy", ] - let selection = await MainActor.run { TalkModeManager.selectTalkProviderConfig(talk) } + let selection = TalkModeManager.selectTalkProviderConfig(talk) #expect(selection?.provider == "elevenlabs") - #expect(selection?.normalizedPayload == true) #expect(selection?.config["voiceId"] as? String == "voice-normalized") } - @Test func fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() async { + @Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() { let talk: [String: Any] = [ "voiceId": "voice-legacy", "apiKey": "legacy-key", ] - let selection = await MainActor.run { TalkModeManager.selectTalkProviderConfig(talk) } - #expect(selection?.provider == "elevenlabs") - #expect(selection?.normalizedPayload == false) - #expect(selection?.config["voiceId"] as? String == "voice-legacy") - #expect(selection?.config["apiKey"] as? String == "legacy-key") + let selection = TalkModeManager.selectTalkProviderConfig(talk) + #expect(selection == nil) } } From 955cc9029f410058bd1fd743da7b380df4944690 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 24 Feb 2026 22:44:57 +0000 Subject: [PATCH 245/408] chore: sync plugin versions to 2026.2.24 --- extensions/bluebubbles/package.json | 5 +---- extensions/copilot-proxy/package.json | 5 +---- extensions/diagnostics-otel/package.json | 5 +---- extensions/discord/package.json | 5 +---- extensions/feishu/package.json | 5 +---- extensions/google-gemini-cli-auth/package.json | 5 +---- extensions/googlechat/package.json | 5 +---- extensions/imessage/package.json | 5 +---- extensions/irc/package.json | 5 +---- extensions/line/package.json | 5 +---- extensions/llm-task/package.json | 2 +- extensions/lobster/package.json | 2 +- extensions/matrix/CHANGELOG.md | 6 ++++++ extensions/matrix/package.json | 5 +---- extensions/mattermost/package.json | 5 +---- extensions/memory-core/package.json | 5 +---- extensions/memory-lancedb/package.json | 5 +---- extensions/minimax-portal-auth/package.json | 5 +---- extensions/msteams/CHANGELOG.md | 6 ++++++ extensions/msteams/package.json | 5 +---- extensions/nextcloud-talk/package.json | 5 +---- extensions/nostr/CHANGELOG.md | 6 ++++++ extensions/nostr/package.json | 5 +---- extensions/open-prose/package.json | 2 +- extensions/signal/package.json | 5 +---- extensions/slack/package.json | 5 +---- extensions/synology-chat/package.json | 5 +---- extensions/telegram/package.json | 5 +---- extensions/tlon/package.json | 5 +---- extensions/twitch/CHANGELOG.md | 6 ++++++ extensions/twitch/package.json | 5 +---- extensions/voice-call/CHANGELOG.md | 6 ++++++ extensions/voice-call/package.json | 5 +---- extensions/whatsapp/package.json | 5 +---- extensions/zalo/CHANGELOG.md | 6 ++++++ extensions/zalo/package.json | 5 +---- extensions/zalouser/CHANGELOG.md | 6 ++++++ extensions/zalouser/package.json | 5 +---- 38 files changed, 73 insertions(+), 115 deletions(-) diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index 102b71717118..42621d894b4c 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,11 +1,8 @@ { "name": "@openclaw/bluebubbles", - "version": "2026.2.23", + "version": "2026.2.24", "description": "OpenClaw BlueBubbles channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 3a3310f7d999..45b04cb35353 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/copilot-proxy", - "version": "2026.2.23", + "version": "2026.2.24", "private": true, "description": "OpenClaw Copilot Proxy provider plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index f35358809cf9..1620812b8e93 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diagnostics-otel", - "version": "2026.2.23", + "version": "2026.2.24", "description": "OpenClaw diagnostics OpenTelemetry exporter", "type": "module", "dependencies": { @@ -16,9 +16,6 @@ "@opentelemetry/sdk-trace-base": "^2.5.1", "@opentelemetry/semantic-conventions": "^1.39.0" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/discord/package.json b/extensions/discord/package.json index dac541368eba..72f8b6c4258e 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,11 +1,8 @@ { "name": "@openclaw/discord", - "version": "2026.2.23", + "version": "2026.2.24", "description": "OpenClaw Discord channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 2eb737280563..23dcdb772905 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/feishu", - "version": "2026.2.23", + "version": "2026.2.24", "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)", "type": "module", "dependencies": { @@ -8,9 +8,6 @@ "@sinclair/typebox": "0.34.48", "zod": "^4.3.6" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index 62fcd6d318e5..736c1a75ab88 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/google-gemini-cli-auth", - "version": "2026.2.23", + "version": "2026.2.24", "private": true, "description": "OpenClaw Gemini CLI OAuth provider plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 95d444f30785..ed2ab0c7fa10 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,15 +1,12 @@ { "name": "@openclaw/googlechat", - "version": "2026.2.23", + "version": "2026.2.24", "private": true, "description": "OpenClaw Google Chat channel plugin", "type": "module", "dependencies": { "google-auth-library": "^10.5.0" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "peerDependencies": { "openclaw": ">=2026.1.26" }, diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 9956c7bb7f42..8d139d6528a7 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/imessage", - "version": "2026.2.23", + "version": "2026.2.24", "private": true, "description": "OpenClaw iMessage channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 876fcb9adb73..acab9d28d0e3 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -1,11 +1,8 @@ { "name": "@openclaw/irc", - "version": "2026.2.23", + "version": "2026.2.24", "description": "OpenClaw IRC channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/line/package.json b/extensions/line/package.json index ef86adea7322..e0206302e787 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/line", - "version": "2026.2.23", + "version": "2026.2.24", "private": true, "description": "OpenClaw LINE channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index 44dc1b46fe11..bade2e1f2e95 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/llm-task", - "version": "2026.2.23", + "version": "2026.2.24", "private": true, "description": "OpenClaw JSON-only LLM task plugin", "type": "module", diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index 4fdbe8bd8877..02a60975427f 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/lobster", - "version": "2026.2.23", + "version": "2026.2.24", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "type": "module", "openclaw": { diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md index fcbaf44e2d94..a040e7664caa 100644 --- a/extensions/matrix/CHANGELOG.md +++ b/extensions/matrix/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index f7ea9f2327c2..4499cd032a66 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/matrix", - "version": "2026.2.23", + "version": "2026.2.24", "description": "OpenClaw Matrix channel plugin", "type": "module", "dependencies": { @@ -10,9 +10,6 @@ "music-metadata": "^11.12.1", "zod": "^4.3.6" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index e1036ea2e398..8e019aa629d3 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,11 +1,8 @@ { "name": "@openclaw/mattermost", - "version": "2026.2.23", + "version": "2026.2.24", "description": "OpenClaw Mattermost channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index aa9102dfccd6..94fc954dda00 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/memory-core", - "version": "2026.2.23", + "version": "2026.2.24", "private": true, "description": "OpenClaw core memory search plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "peerDependencies": { "openclaw": ">=2026.1.26" }, diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index 12610d18e9e4..3925a7a534b7 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-lancedb", - "version": "2026.2.23", + "version": "2026.2.24", "private": true, "description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture", "type": "module", @@ -9,9 +9,6 @@ "@sinclair/typebox": "0.34.48", "openai": "^6.22.0" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json index f61b1e019678..dc55da4d7533 100644 --- a/extensions/minimax-portal-auth/package.json +++ b/extensions/minimax-portal-auth/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/minimax-portal-auth", - "version": "2026.2.23", + "version": "2026.2.24", "private": true, "description": "OpenClaw MiniMax Portal OAuth provider plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index 5859decd9efb..0b76b055c153 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 1dd46e1d788e..2dce7475f940 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,15 +1,12 @@ { "name": "@openclaw/msteams", - "version": "2026.2.23", + "version": "2026.2.24", "description": "OpenClaw Microsoft Teams channel plugin", "type": "module", "dependencies": { "@microsoft/agents-hosting": "^1.3.1", "express": "^5.2.1" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index 772cffe238c1..210bcb0993ed 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,11 +1,8 @@ { "name": "@openclaw/nextcloud-talk", - "version": "2026.2.23", + "version": "2026.2.24", "description": "OpenClaw Nextcloud Talk channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md index b0b7d0c81d30..9d3667c3c8f9 100644 --- a/extensions/nostr/CHANGELOG.md +++ b/extensions/nostr/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index c8583c392a3b..ffa3cba5c419 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,15 +1,12 @@ { "name": "@openclaw/nostr", - "version": "2026.2.23", + "version": "2026.2.24", "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs", "type": "module", "dependencies": { "nostr-tools": "^2.23.1", "zod": "^4.3.6" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index 038a5d7f3a7f..157f546e2f7f 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/open-prose", - "version": "2026.2.23", + "version": "2026.2.24", "private": true, "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "type": "module", diff --git a/extensions/signal/package.json b/extensions/signal/package.json index bec90b982330..bf82f1e703b3 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/signal", - "version": "2026.2.23", + "version": "2026.2.24", "private": true, "description": "OpenClaw Signal channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 8541fffd0143..530a8132082c 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/slack", - "version": "2026.2.23", + "version": "2026.2.24", "private": true, "description": "OpenClaw Slack channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index 230bcc80b3d4..940ce593aba3 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -1,14 +1,11 @@ { "name": "@openclaw/synology-chat", - "version": "2026.2.23", + "version": "2026.2.24", "description": "Synology Chat channel plugin for OpenClaw", "type": "module", "dependencies": { "zod": "^4.3.6" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index e6d9d0aff850..fa596ad0209a 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/telegram", - "version": "2026.2.23", + "version": "2026.2.24", "private": true, "description": "OpenClaw Telegram channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index ae5079b29ade..ec18f0616fd7 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,14 +1,11 @@ { "name": "@openclaw/tlon", - "version": "2026.2.23", + "version": "2026.2.24", "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { "@urbit/aura": "^3.0.0" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/twitch/CHANGELOG.md b/extensions/twitch/CHANGELOG.md index 238484b49d76..4910a82d5c74 100644 --- a/extensions/twitch/CHANGELOG.md +++ b/extensions/twitch/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 92a86c09c2a0..54285d122257 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/twitch", - "version": "2026.2.23", + "version": "2026.2.24", "description": "OpenClaw Twitch channel plugin", "type": "module", "dependencies": { @@ -9,9 +9,6 @@ "@twurple/chat": "^8.0.3", "zod": "^4.3.6" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index 0b7c63a3e431..e41b0f6219f7 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index cb636350415d..56d772b28abd 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/voice-call", - "version": "2026.2.23", + "version": "2026.2.24", "description": "OpenClaw voice-call plugin", "type": "module", "dependencies": { @@ -8,9 +8,6 @@ "ws": "^8.19.0", "zod": "^4.3.6" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 60dd015f6ade..bed133601b5e 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/whatsapp", - "version": "2026.2.23", + "version": "2026.2.24", "private": true, "description": "OpenClaw WhatsApp channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index 3be1369d6233..bf644f83fe77 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index b7172deaaeeb..e5e23bdee20b 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,14 +1,11 @@ { "name": "@openclaw/zalo", - "version": "2026.2.23", + "version": "2026.2.24", "description": "OpenClaw Zalo channel plugin", "type": "module", "dependencies": { "undici": "7.22.0" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md index 4e03fa2d3734..cb2dafc5a829 100644 --- a/extensions/zalouser/CHANGELOG.md +++ b/extensions/zalouser/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 7a76c1553f2d..f6530d74c931 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,14 +1,11 @@ { "name": "@openclaw/zalouser", - "version": "2026.2.23", + "version": "2026.2.24", "description": "OpenClaw Zalo Personal Account plugin via zca-cli", "type": "module", "dependencies": { "@sinclair/typebox": "0.34.48" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" From 039ae0b77c8626b6a16cba621b7a265639f34a96 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 24 Feb 2026 22:50:47 +0000 Subject: [PATCH 246/408] chore: refresh lockfile after plugin devDependency cleanup --- pnpm-lock.yaml | 228 ++++++++++++++++++++++--------------------------- 1 file changed, 103 insertions(+), 125 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8c7b81bb33b..cd21887d7a89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -248,17 +248,9 @@ importers: specifier: ^0.10.0 version: 0.10.0 - extensions/bluebubbles: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/bluebubbles: {} - extensions/copilot-proxy: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/copilot-proxy: {} extensions/diagnostics-otel: dependencies: @@ -295,16 +287,8 @@ importers: '@opentelemetry/semantic-conventions': specifier: ^1.39.0 version: 1.39.0 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/discord: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/discord: {} extensions/feishu: dependencies: @@ -317,44 +301,23 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/google-gemini-cli-auth: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/google-gemini-cli-auth: {} extensions/googlechat: dependencies: google-auth-library: specifier: ^10.5.0 version: 10.5.0 - devDependencies: openclaw: - specifier: workspace:* - version: link:../.. + specifier: '>=2026.1.26' + version: 2026.2.23(@napi-rs/canvas@0.1.94)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) - extensions/imessage: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/imessage: {} - extensions/irc: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/irc: {} - extensions/line: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/line: {} extensions/llm-task: {} @@ -377,22 +340,14 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/mattermost: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/mattermost: {} extensions/memory-core: - devDependencies: + dependencies: openclaw: - specifier: workspace:* - version: link:../.. + specifier: '>=2026.1.26' + version: 2026.2.23(@napi-rs/canvas@0.1.94)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) extensions/memory-lancedb: dependencies: @@ -405,16 +360,8 @@ importers: openai: specifier: ^6.22.0 version: 6.22.0(ws@8.19.0)(zod@4.3.6) - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/minimax-portal-auth: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/minimax-portal-auth: {} extensions/msteams: dependencies: @@ -424,16 +371,8 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/nextcloud-talk: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/nextcloud-talk: {} extensions/nostr: dependencies: @@ -443,50 +382,26 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. extensions/open-prose: {} - extensions/signal: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/signal: {} - extensions/slack: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/slack: {} extensions/synology-chat: dependencies: zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/telegram: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/telegram: {} extensions/tlon: dependencies: '@urbit/aura': specifier: ^3.0.0 version: 3.0.0 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. extensions/twitch: dependencies: @@ -502,10 +417,6 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. extensions/voice-call: dependencies: @@ -518,36 +429,20 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/whatsapp: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/whatsapp: {} extensions/zalo: dependencies: undici: specifier: 7.22.0 version: 7.22.0 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. extensions/zalouser: dependencies: '@sinclair/typebox': specifier: 0.34.48 version: 0.34.48 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. packages/clawdbot: dependencies: @@ -4728,6 +4623,14 @@ packages: zod: optional: true + openclaw@2026.2.23: + resolution: {integrity: sha512-7I7G898212v3OzUidgM8kZdZYAziT78Dc5zgeqsV2tfCbINtHK0Pdc2rg2eDLoDYAcheLh0fvH5qn/15Yu9q7A==} + engines: {node: '>=22.12.0'} + hasBin: true + peerDependencies: + '@napi-rs/canvas': ^0.1.89 + node-llama-cpp: 3.15.1 + opus-decoder@0.7.11: resolution: {integrity: sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==} @@ -7198,7 +7101,6 @@ snapshots: '@napi-rs/canvas-linux-x64-musl': 0.1.94 '@napi-rs/canvas-win32-arm64-msvc': 0.1.94 '@napi-rs/canvas-win32-x64-msvc': 0.1.94 - optional: true '@napi-rs/wasm-runtime@1.1.1': dependencies: @@ -10484,6 +10386,82 @@ snapshots: ws: 8.19.0 zod: 4.3.6 + openclaw@2026.2.23(@napi-rs/canvas@0.1.94)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)): + dependencies: + '@agentclientprotocol/sdk': 0.14.1(zod@4.3.6) + '@aws-sdk/client-bedrock': 3.995.0 + '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.0.8) + '@clack/prompts': 1.0.1 + '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8) + '@grammyjs/runner': 2.0.3(grammy@1.40.0) + '@grammyjs/transformer-throttler': 1.2.1(grammy@1.40.0) + '@homebridge/ciao': 1.3.5 + '@larksuiteoapi/node-sdk': 1.59.0 + '@line/bot-sdk': 10.6.0 + '@lydell/node-pty': 1.2.0-beta.3 + '@mariozechner/pi-agent-core': 0.54.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.54.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-coding-agent': 0.54.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.54.1 + '@mozilla/readability': 0.6.0 + '@napi-rs/canvas': 0.1.94 + '@sinclair/typebox': 0.34.48 + '@slack/bolt': 4.6.0(@types/express@5.0.6) + '@slack/web-api': 7.14.1 + '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) + ajv: 8.18.0 + chalk: 5.6.2 + chokidar: 5.0.0 + cli-highlight: 2.1.11 + commander: 14.0.3 + croner: 10.0.1 + discord-api-types: 0.38.40 + dotenv: 17.3.1 + express: 5.2.1 + file-type: 21.3.0 + grammy: 1.40.0 + https-proxy-agent: 7.0.6 + ipaddr.js: 2.3.0 + jiti: 2.6.1 + json5: 2.2.3 + jszip: 3.10.1 + linkedom: 0.18.12 + long: 5.3.2 + markdown-it: 14.1.1 + node-edge-tts: 1.2.10 + node-llama-cpp: 3.15.1(typescript@5.9.3) + opusscript: 0.0.8 + osc-progress: 0.3.0 + pdfjs-dist: 5.4.624 + playwright-core: 1.58.2 + qrcode-terminal: 0.12.0 + sharp: 0.34.5 + sqlite-vec: 0.1.7-alpha.2 + tar: 7.5.9 + tslog: 4.10.2 + undici: 7.22.0 + ws: 8.19.0 + yaml: 2.8.2 + zod: 4.3.6 + optionalDependencies: + '@discordjs/opus': 0.10.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - '@types/express' + - audio-decode + - aws-crt + - bufferutil + - canvas + - debug + - encoding + - ffmpeg-static + - hono + - jimp + - link-preview-js + - node-opus + - supports-color + - utf-8-validate + opus-decoder@0.7.11: dependencies: '@wasm-audio-decoders/common': 9.0.7 From bf8ca07deb704b7f50a1db792f88c93e7a4e15be Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:02:45 +0000 Subject: [PATCH 247/408] fix(config): soften antigravity removal fallout (#25538) Land #25538 by @chilu18 to keep legacy google-antigravity-auth config entries non-fatal after removal (see #25862). Co-authored-by: chilu18 --- CHANGELOG.md | 1 + src/config/config.plugin-validation.test.ts | 42 +++++++++++++++++++++ src/config/validation.ts | 35 +++++++++-------- 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb89d49e0eb..a87de8b23ac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. - Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index b9fb08e4d8d3..d9e6b3190e17 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -103,6 +103,48 @@ describe("config plugin validation", () => { } }); + it("warns for removed legacy plugin ids instead of failing validation", async () => { + const home = await createCaseHome(); + const removedId = "google-antigravity-auth"; + const res = validateInHome(home, { + agents: { list: [{ id: "pi" }] }, + plugins: { + enabled: false, + entries: { [removedId]: { enabled: true } }, + allow: [removedId], + deny: [removedId], + slots: { memory: removedId }, + }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.warnings).toEqual( + expect.arrayContaining([ + { + path: `plugins.entries.${removedId}`, + message: + "plugin removed: google-antigravity-auth (stale config entry ignored; remove it from plugins config)", + }, + { + path: "plugins.allow", + message: + "plugin removed: google-antigravity-auth (stale config entry ignored; remove it from plugins config)", + }, + { + path: "plugins.deny", + message: + "plugin removed: google-antigravity-auth (stale config entry ignored; remove it from plugins config)", + }, + { + path: "plugins.slots.memory", + message: + "plugin removed: google-antigravity-auth (stale config entry ignored; remove it from plugins config)", + }, + ]), + ); + } + }); + it("surfaces plugin config diagnostics", async () => { const home = await createCaseHome(); const pluginDir = path.join(home, "bad-plugin"); diff --git a/src/config/validation.ts b/src/config/validation.ts index f2ee18674855..746f89ef0e4a 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -22,6 +22,8 @@ import { findLegacyConfigIssues } from "./legacy.js"; import type { OpenClawConfig, ConfigValidationIssue } from "./types.js"; import { OpenClawSchema } from "./zod-schema.js"; +const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth"]); + function isWorkspaceAvatarPath(value: string, workspaceDir: string): boolean { const workspaceRoot = path.resolve(workspaceDir); const resolved = path.resolve(workspaceRoot, value); @@ -313,6 +315,19 @@ function validateConfigObjectWithPluginsBase( } const { registry, knownIds, normalizedPlugins } = ensureRegistry(); + const pushMissingPluginIssue = (path: string, pluginId: string) => { + if (LEGACY_REMOVED_PLUGIN_IDS.has(pluginId)) { + warnings.push({ + path, + message: `plugin removed: ${pluginId} (stale config entry ignored; remove it from plugins config)`, + }); + return; + } + issues.push({ + path, + message: `plugin not found: ${pluginId}`, + }); + }; const pluginsConfig = config.plugins; @@ -320,10 +335,7 @@ function validateConfigObjectWithPluginsBase( if (entries && isRecord(entries)) { for (const pluginId of Object.keys(entries)) { if (!knownIds.has(pluginId)) { - issues.push({ - path: `plugins.entries.${pluginId}`, - message: `plugin not found: ${pluginId}`, - }); + pushMissingPluginIssue(`plugins.entries.${pluginId}`, pluginId); } } } @@ -334,10 +346,7 @@ function validateConfigObjectWithPluginsBase( continue; } if (!knownIds.has(pluginId)) { - issues.push({ - path: "plugins.allow", - message: `plugin not found: ${pluginId}`, - }); + pushMissingPluginIssue("plugins.allow", pluginId); } } @@ -347,19 +356,13 @@ function validateConfigObjectWithPluginsBase( continue; } if (!knownIds.has(pluginId)) { - issues.push({ - path: "plugins.deny", - message: `plugin not found: ${pluginId}`, - }); + pushMissingPluginIssue("plugins.deny", pluginId); } } const memorySlot = normalizedPlugins.slots.memory; if (typeof memorySlot === "string" && memorySlot.trim() && !knownIds.has(memorySlot)) { - issues.push({ - path: "plugins.slots.memory", - message: `plugin not found: ${memorySlot}`, - }); + pushMissingPluginIssue("plugins.slots.memory", memorySlot); } let selectedMemoryPluginId: string | null = null; From d3da67c7a9b463edc1a9b1c1f7af107a34ca32f5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:09:34 +0000 Subject: [PATCH 248/408] fix(security): lock sandbox tmp media paths to openclaw roots --- CHANGELOG.md | 1 + extensions/llm-task/src/llm-task-tool.ts | 6 +- package.json | 3 +- scripts/check-no-random-messaging-tmp.mjs | 173 ++++++++++++++++++ src/agents/sandbox-paths.test.ts | 41 +++-- src/agents/sandbox-paths.ts | 7 +- src/infra/outbound/deliver.test.ts | 81 ++++++++ .../outbound/message-action-runner.test.ts | 22 ++- src/media-understanding/runner.entries.ts | 6 +- src/plugin-sdk/index.ts | 1 + src/plugin-sdk/temp-path.test.ts | 8 +- src/plugin-sdk/temp-path.ts | 10 +- .../check-no-random-messaging-tmp.test.ts | 36 ++++ 13 files changed, 364 insertions(+), 31 deletions(-) create mode 100644 scripts/check-no-random-messaging-tmp.mjs create mode 100644 test/scripts/check-no-random-messaging-tmp.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a87de8b23ac3..cf40d8a20aab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. - Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index 87588d7adbd1..6a58118618cd 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { Type } from "@sinclair/typebox"; import Ajv from "ajv"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk"; // 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). @@ -180,7 +180,9 @@ export function createLlmTaskTool(api: OpenClawPluginApi) { let tmpDir: string | null = null; try { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-llm-task-")); + tmpDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-llm-task-"), + ); const sessionId = `llm-task-${Date.now()}`; const sessionFile = path.join(tmpDir, "session.json"); diff --git a/package.json b/package.json index 66a60a5dc00b..69657a04cf22 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm format:check && pnpm tsgo && pnpm lint", + "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", "deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-prune && pnpm deadcode:report:ci:ts-unused", @@ -93,6 +93,7 @@ "lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix", "lint:fix": "oxlint --type-aware --fix && pnpm format", "lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", + "lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs", "lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs", "mac:open": "open dist/OpenClaw.app", "mac:package": "bash scripts/package-mac-app.sh", diff --git a/scripts/check-no-random-messaging-tmp.mjs b/scripts/check-no-random-messaging-tmp.mjs new file mode 100644 index 000000000000..c2d6395f4ddc --- /dev/null +++ b/scripts/check-no-random-messaging-tmp.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceRoots = [ + path.join(repoRoot, "src", "channels"), + path.join(repoRoot, "src", "infra", "outbound"), + path.join(repoRoot, "src", "line"), + path.join(repoRoot, "src", "media-understanding"), + path.join(repoRoot, "extensions"), +]; +const allowedCallsites = new Set([path.join(repoRoot, "extensions", "feishu", "src", "dedup.ts")]); + +function isTestLikeFile(filePath) { + return ( + filePath.endsWith(".test.ts") || + filePath.endsWith(".test-utils.ts") || + filePath.endsWith(".test-harness.ts") || + filePath.endsWith(".e2e-harness.ts") + ); +} + +async function collectTypeScriptFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const out = []; + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...(await collectTypeScriptFiles(entryPath))); + continue; + } + if (!entry.isFile()) { + continue; + } + if (!entryPath.endsWith(".ts")) { + continue; + } + if (isTestLikeFile(entryPath)) { + continue; + } + out.push(entryPath); + } + return out; +} + +function collectNodeOsImports(sourceFile) { + const osNamespaceOrDefault = new Set(); + const namedTmpdir = new Set(); + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) { + continue; + } + if (!statement.importClause || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + if (statement.moduleSpecifier.text !== "node:os") { + continue; + } + const clause = statement.importClause; + if (clause.name) { + osNamespaceOrDefault.add(clause.name.text); + } + if (!clause.namedBindings) { + continue; + } + if (ts.isNamespaceImport(clause.namedBindings)) { + osNamespaceOrDefault.add(clause.namedBindings.name.text); + continue; + } + for (const element of clause.namedBindings.elements) { + if ((element.propertyName?.text ?? element.name.text) === "tmpdir") { + namedTmpdir.add(element.name.text); + } + } + } + return { osNamespaceOrDefault, namedTmpdir }; +} + +function unwrapExpression(expression) { + let current = expression; + while (true) { + if (ts.isParenthesizedExpression(current)) { + current = current.expression; + continue; + } + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + current = current.expression; + continue; + } + if (ts.isNonNullExpression(current)) { + current = current.expression; + continue; + } + return current; + } +} + +export function findMessagingTmpdirCallLines(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const { osNamespaceOrDefault, namedTmpdir } = collectNodeOsImports(sourceFile); + const lines = []; + + const visit = (node) => { + if (ts.isCallExpression(node)) { + const callee = unwrapExpression(node.expression); + if ( + ts.isPropertyAccessExpression(callee) && + callee.name.text === "tmpdir" && + ts.isIdentifier(callee.expression) && + osNamespaceOrDefault.has(callee.expression.text) + ) { + const line = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile)).line + 1; + lines.push(line); + } else if (ts.isIdentifier(callee) && namedTmpdir.has(callee.text)) { + const line = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile)).line + 1; + lines.push(line); + } + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return lines; +} + +export async function main() { + const files = ( + await Promise.all(sourceRoots.map(async (dir) => await collectTypeScriptFiles(dir))) + ).flat(); + const violations = []; + + for (const filePath of files) { + if (allowedCallsites.has(filePath)) { + continue; + } + const content = await fs.readFile(filePath, "utf8"); + for (const line of findMessagingTmpdirCallLines(content, filePath)) { + violations.push(`${path.relative(repoRoot, filePath)}:${line}`); + } + } + + if (violations.length === 0) { + return; + } + + console.error("Found os.tmpdir()/tmpdir() usage in messaging/channel runtime sources:"); + for (const violation of violations) { + console.error(`- ${violation}`); + } + console.error( + "Use resolvePreferredOpenClawTmpDir() or plugin-sdk temp helpers instead of host tmp defaults.", + ); + process.exit(1); +} + +const isDirectExecution = (() => { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === fileURLToPath(import.meta.url); +})(); + +if (isDirectExecution) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/src/agents/sandbox-paths.test.ts b/src/agents/sandbox-paths.test.ts index de317320a806..6111980e1384 100644 --- a/src/agents/sandbox-paths.test.ts +++ b/src/agents/sandbox-paths.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { resolveSandboxedMediaSource } from "./sandbox-paths.js"; async function withSandboxRoot(run: (sandboxDir: string) => Promise) { @@ -24,22 +25,24 @@ function isPathInside(root: string, target: string): boolean { } describe("resolveSandboxedMediaSource", () => { + const openClawTmpDir = resolvePreferredOpenClawTmpDir(); + // Group 1: /tmp paths (the bug fix) it.each([ { - name: "absolute paths under os.tmpdir()", - media: path.join(os.tmpdir(), "image.png"), - expected: path.join(os.tmpdir(), "image.png"), + name: "absolute paths under preferred OpenClaw tmp root", + media: path.join(openClawTmpDir, "image.png"), + expected: path.join(openClawTmpDir, "image.png"), }, { - name: "file:// URLs pointing to os.tmpdir()", - media: pathToFileURL(path.join(os.tmpdir(), "photo.png")).href, - expected: path.join(os.tmpdir(), "photo.png"), + name: "file:// URLs pointing to preferred OpenClaw tmp root", + media: pathToFileURL(path.join(openClawTmpDir, "photo.png")).href, + expected: path.join(openClawTmpDir, "photo.png"), }, { - name: "nested paths under os.tmpdir()", - media: path.join(os.tmpdir(), "subdir", "deep", "file.png"), - expected: path.join(os.tmpdir(), "subdir", "deep", "file.png"), + name: "nested paths under preferred OpenClaw tmp root", + media: path.join(openClawTmpDir, "subdir", "deep", "file.png"), + expected: path.join(openClawTmpDir, "subdir", "deep", "file.png"), }, ])("allows $name", async ({ media, expected }) => { await withSandboxRoot(async (sandboxDir) => { @@ -96,7 +99,12 @@ describe("resolveSandboxedMediaSource", () => { }, { name: "path traversal through tmpdir", - media: path.join(os.tmpdir(), "..", "etc", "passwd"), + media: path.join(openClawTmpDir, "..", "etc", "passwd"), + expected: /sandbox/i, + }, + { + name: "absolute paths under host tmp outside openclaw tmp root", + media: path.join(os.tmpdir(), "outside-openclaw", "passwd"), expected: /sandbox/i, }, { @@ -120,20 +128,25 @@ describe("resolveSandboxedMediaSource", () => { }); }); - it("rejects symlinked tmpdir paths escaping tmpdir", async () => { + it("rejects symlinked OpenClaw tmp paths escaping tmp root", async () => { if (process.platform === "win32") { return; } const outsideTmpTarget = path.resolve(process.cwd(), "package.json"); - if (isPathInside(os.tmpdir(), outsideTmpTarget)) { + if (isPathInside(openClawTmpDir, outsideTmpTarget)) { return; } await withSandboxRoot(async (sandboxDir) => { await fs.access(outsideTmpTarget); - const symlinkPath = path.join(sandboxDir, "tmp-link-escape"); + await fs.mkdir(openClawTmpDir, { recursive: true }); + const symlinkPath = path.join(openClawTmpDir, `tmp-link-escape-${process.pid}`); await fs.symlink(outsideTmpTarget, symlinkPath); - await expectSandboxRejection(symlinkPath, sandboxDir, /symlink|sandbox/i); + try { + await expectSandboxRejection(symlinkPath, sandboxDir, /symlink|sandbox/i); + } finally { + await fs.unlink(symlinkPath).catch(() => {}); + } }); }); diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index 5b684bbe4252..f5ae24ac16af 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath, URL } from "node:url"; import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; const HTTP_URL_RE = /^https?:\/\//i; @@ -181,11 +182,11 @@ async function resolveAllowedTmpMediaPath(params: { return undefined; } const resolved = path.resolve(resolveSandboxInputPath(params.candidate, params.sandboxRoot)); - const tmpDir = path.resolve(os.tmpdir()); - if (!isPathInside(tmpDir, resolved)) { + const openClawTmpDir = path.resolve(resolvePreferredOpenClawTmpDir()); + if (!isPathInside(openClawTmpDir, resolved)) { return undefined; } - await assertNoSymlinkEscape(path.relative(tmpDir, resolved), tmpDir); + await assertNoSymlinkEscape(path.relative(openClawTmpDir, resolved), openClawTmpDir); return resolved; } diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index c39d966b804e..94b5bee9891a 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -11,6 +11,7 @@ import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/c import { withEnvAsync } from "../../test-utils/env.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js"; +import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; const mocks = vi.hoisted(() => ({ appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })), @@ -202,6 +203,86 @@ describe("deliverOutboundPayloads", () => { ); }); + it("includes OpenClaw tmp root in telegram mediaLocalRoots", async () => { + const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); + + await deliverOutboundPayloads({ + cfg: telegramChunkConfig, + channel: "telegram", + to: "123", + payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledWith( + "123", + "hi", + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]), + }), + ); + }); + + it("includes OpenClaw tmp root in signal mediaLocalRoots", async () => { + const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 }); + + await deliverOutboundPayloads({ + cfg: { channels: { signal: {} } }, + channel: "signal", + to: "+1555", + payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], + deps: { sendSignal }, + }); + + expect(sendSignal).toHaveBeenCalledWith( + "+1555", + "hi", + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]), + }), + ); + }); + + it("includes OpenClaw tmp root in whatsapp mediaLocalRoots", async () => { + const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" }); + + await deliverOutboundPayloads({ + cfg: whatsappChunkConfig, + channel: "whatsapp", + to: "+1555", + payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], + deps: { sendWhatsApp }, + }); + + expect(sendWhatsApp).toHaveBeenCalledWith( + "+1555", + "hi", + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]), + }), + ); + }); + + it("includes OpenClaw tmp root in imessage mediaLocalRoots", async () => { + const sendIMessage = vi.fn().mockResolvedValue({ messageId: "i1", chatId: "chat-1" }); + + await deliverOutboundPayloads({ + cfg: {}, + channel: "imessage", + to: "imessage:+15551234567", + payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], + deps: { sendIMessage }, + }); + + expect(sendIMessage).toHaveBeenCalledWith( + "imessage:+15551234567", + "hi", + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]), + }), + ); + }); + it("uses signal media maxBytes from config", async () => { const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 }); const cfg: OpenClawConfig = { channels: { signal: { mediaMaxMb: 2 } } }; diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index f2c36464e975..26591ff23c90 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -12,6 +12,7 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { loadWebMedia } from "../../web/media.js"; +import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; import { runMessageAction } from "./message-action-runner.js"; vi.mock("../../web/media.js", async () => { @@ -622,10 +623,12 @@ describe("runMessageAction sandboxed media validation", () => { }); }); - it("allows media paths under os.tmpdir()", async () => { + it("allows media paths under preferred OpenClaw tmp root", async () => { + const tmpRoot = resolvePreferredOpenClawTmpDir(); + await fs.mkdir(tmpRoot, { recursive: true }); const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-")); try { - const tmpFile = path.join(os.tmpdir(), "test-media-image.png"); + const tmpFile = path.join(tmpRoot, "test-media-image.png"); const result = await runMessageAction({ cfg: slackConfig, action: "send", @@ -644,6 +647,21 @@ describe("runMessageAction sandboxed media validation", () => { throw new Error("expected send result"); } expect(result.sendResult?.mediaUrl).toBe(tmpFile); + const hostTmpOutsideOpenClaw = path.join(os.tmpdir(), "outside-openclaw", "test-media.png"); + await expect( + runMessageAction({ + cfg: slackConfig, + action: "send", + params: { + channel: "slack", + target: "#C12345678", + media: hostTmpOutsideOpenClaw, + message: "", + }, + sandboxRoot: sandboxDir, + dryRun: true, + }), + ).rejects.toThrow(/sandbox/i); } finally { await fs.rm(sandboxDir, { recursive: true, force: true }); } diff --git a/src/media-understanding/runner.entries.ts b/src/media-understanding/runner.entries.ts index 3e80caae9bcf..36e6a89b4388 100644 --- a/src/media-understanding/runner.entries.ts +++ b/src/media-understanding/runner.entries.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { collectProviderApiKeysForExecution, @@ -14,6 +13,7 @@ import type { MediaUnderstandingModelConfig, } from "../config/types.tools.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { runExec } from "../process/exec.js"; import { MediaAttachmentCache } from "./attachments.js"; import { @@ -566,7 +566,9 @@ export async function runCliEntry(params: { maxBytes, timeoutMs, }); - const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-cli-")); + const outputDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-media-cli-"), + ); const mediaPath = pathResult.path; const outputBase = path.join(outputDir, path.parse(mediaPath).name); diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 461370054b0d..9c54fe175f69 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -200,6 +200,7 @@ export { createLoggerBackedRuntime } from "./runtime.js"; export { chunkTextForOutbound } from "./text-chunking.js"; export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; export { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js"; +export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; export { runPluginCommandWithTimeout, type PluginCommandRunOptions, diff --git a/src/plugin-sdk/temp-path.test.ts b/src/plugin-sdk/temp-path.test.ts index dbd2d46ee0ff..166a2373b15f 100644 --- a/src/plugin-sdk/temp-path.test.ts +++ b/src/plugin-sdk/temp-path.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js"; describe("buildRandomTempFilePath", () => { @@ -17,13 +17,13 @@ describe("buildRandomTempFilePath", () => { }); it("sanitizes prefix and extension to avoid path traversal segments", () => { + const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir()); const result = buildRandomTempFilePath({ prefix: "../../line/../media", extension: "/../.jpg", now: 123, uuid: "abc", }); - const tmpRoot = path.resolve(os.tmpdir()); const resolved = path.resolve(result); const rel = path.relative(tmpRoot, resolved); expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); @@ -45,11 +45,12 @@ describe("withTempDownloadPath", () => { }, ); - expect(capturedPath).toContain(path.join(os.tmpdir(), "line-media-")); + expect(capturedPath).toContain(path.join(resolvePreferredOpenClawTmpDir(), "line-media-")); await expect(fs.stat(capturedPath)).rejects.toMatchObject({ code: "ENOENT" }); }); it("sanitizes prefix and fileName", async () => { + const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir()); let capturedPath = ""; await withTempDownloadPath( { @@ -61,7 +62,6 @@ describe("withTempDownloadPath", () => { }, ); - const tmpRoot = path.resolve(os.tmpdir()); const resolved = path.resolve(capturedPath); const rel = path.relative(tmpRoot, resolved); expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); diff --git a/src/plugin-sdk/temp-path.ts b/src/plugin-sdk/temp-path.ts index ed1b149135af..f0ca73b2109e 100644 --- a/src/plugin-sdk/temp-path.ts +++ b/src/plugin-sdk/temp-path.ts @@ -1,7 +1,7 @@ import crypto from "node:crypto"; import { mkdtemp, rm } from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; function sanitizePrefix(prefix: string): string { const normalized = prefix.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""); @@ -27,6 +27,10 @@ function sanitizeFileName(fileName: string): string { return normalized || "download.bin"; } +function resolveTempRoot(tmpDir?: string): string { + return tmpDir ?? resolvePreferredOpenClawTmpDir(); +} + export function buildRandomTempFilePath(params: { prefix: string; extension?: string; @@ -42,7 +46,7 @@ export function buildRandomTempFilePath(params: { ? Math.trunc(nowCandidate) : Date.now(); const uuid = params.uuid?.trim() || crypto.randomUUID(); - return path.join(params.tmpDir ?? os.tmpdir(), `${prefix}-${now}-${uuid}${extension}`); + return path.join(resolveTempRoot(params.tmpDir), `${prefix}-${now}-${uuid}${extension}`); } export async function withTempDownloadPath( @@ -53,7 +57,7 @@ export async function withTempDownloadPath( }, fn: (tmpPath: string) => Promise, ): Promise { - const tempRoot = params.tmpDir ?? os.tmpdir(); + const tempRoot = resolveTempRoot(params.tmpDir); const prefix = `${sanitizePrefix(params.prefix)}-`; const dir = await mkdtemp(path.join(tempRoot, prefix)); const tmpPath = path.join(dir, sanitizeFileName(params.fileName ?? "download.bin")); diff --git a/test/scripts/check-no-random-messaging-tmp.test.ts b/test/scripts/check-no-random-messaging-tmp.test.ts new file mode 100644 index 000000000000..01495b2b09bc --- /dev/null +++ b/test/scripts/check-no-random-messaging-tmp.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { findMessagingTmpdirCallLines } from "../../scripts/check-no-random-messaging-tmp.mjs"; + +describe("check-no-random-messaging-tmp", () => { + it("finds os.tmpdir calls imported from node:os", () => { + const source = ` + import os from "node:os"; + const dir = os.tmpdir(); + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([3]); + }); + + it("finds tmpdir named import calls from node:os", () => { + const source = ` + import { tmpdir } from "node:os"; + const dir = tmpdir(); + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([3]); + }); + + it("ignores mentions in comments and strings", () => { + const source = ` + // os.tmpdir() + const text = "tmpdir()"; + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([]); + }); + + it("ignores tmpdir symbols that are not imported from node:os", () => { + const source = ` + const tmpdir = () => "/tmp"; + const dir = tmpdir(); + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([]); + }); +}); From 2d159e5e878f79f470f9642772a018ce53f6724f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:11:19 +0000 Subject: [PATCH 249/408] docs(security): document openclaw temp-folder boundary --- SECURITY.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index fe6daa332cae..dcda446ad907 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -159,6 +159,19 @@ Plugins/extensions are loaded **in-process** with the Gateway and are treated as - Runtime helpers (for example `runtime.system.runCommandWithTimeout`) are convenience APIs, not a sandbox boundary. - Only install plugins you trust, and prefer `plugins.allow` to pin explicit trusted plugin ids. +## Temp Folder Boundary (Media/Sandbox) + +OpenClaw uses a dedicated temp root for local media handoff and sandbox-adjacent temp artifacts: + +- Preferred temp root: `/tmp/openclaw` (when available and safe on the host). +- Fallback temp root: `os.tmpdir()/openclaw` (or `openclaw-` on multi-user hosts). + +Security boundary notes: + +- Sandbox media validation allows absolute temp paths only under the OpenClaw-managed temp root. +- Arbitrary host tmp paths are not treated as trusted media roots. +- Plugin/extension code should use OpenClaw temp helpers (`resolvePreferredOpenClawTmpDir`, `buildRandomTempFilePath`, `withTempDownloadPath`) rather than raw `os.tmpdir()` defaults when handling media files. + ## Operational Guidance For threat model + hardening guidance (including `openclaw security audit --deep` and `--fix`), see: From b67e600bff696ff2ed9b470826590c0ce6b3bb0a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:12:52 +0000 Subject: [PATCH 250/408] fix(security): restrict default safe-bin trusted dirs --- CHANGELOG.md | 1 + docs/tools/exec-approvals.md | 4 ++++ docs/tools/exec.md | 2 +- src/infra/exec-safe-bin-runtime-policy.test.ts | 14 ++++++++++++++ src/infra/exec-safe-bin-trust.test.ts | 9 +++++++++ src/infra/exec-safe-bin-trust.ts | 12 +++--------- 6 files changed, 32 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf40d8a20aab..fa6315648c4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), preventing writable-dir binary shadowing from auto-satisfying safe-bin allowlist checks. This ships in the next npm release. Thanks @tdjackey for reporting. - Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. - Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. - Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index f155fbbd7905..619f5cdb38e5 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -165,6 +165,10 @@ and no `$VARS` expansion) for stdin-only segments, so patterns like `*` or `$HOM used to smuggle file reads. Safe bins must also resolve from trusted binary directories (system defaults plus optional `tools.exec.safeBinTrustedDirs`). `PATH` entries are never auto-trusted. +Default trusted safe-bin directories are intentionally minimal: `/bin`, `/usr/bin`. +If your safe-bin executable lives in package-manager/user paths (for example +`/opt/homebrew/bin`, `/usr/local/bin`, `/opt/local/bin`, `/snap/bin`), add them explicitly +to `tools.exec.safeBinTrustedDirs`. Shell chaining and redirections are not auto-allowed in allowlist mode. Shell chaining (`&&`, `||`, `;`) is allowed when every top-level segment satisfies the allowlist diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 1dc5cc4fc1de..a52af45fdcbd 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -55,7 +55,7 @@ Notes: - `tools.exec.node` (default: unset) - `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs (gateway + sandbox only). - `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries. For behavior details, see [Safe bins](/tools/exec-approvals#safe-bins-stdin-only). -- `tools.exec.safeBinTrustedDirs`: additional explicit directories trusted for `safeBins` path checks. `PATH` entries are never auto-trusted. +- `tools.exec.safeBinTrustedDirs`: additional explicit directories trusted for `safeBins` path checks. `PATH` entries are never auto-trusted. Built-in defaults are `/bin` and `/usr/bin`. - `tools.exec.safeBinProfiles`: optional custom argv policy per safe bin (`minPositional`, `maxPositional`, `allowedValueFlags`, `deniedFlags`). Example: diff --git a/src/infra/exec-safe-bin-runtime-policy.test.ts b/src/infra/exec-safe-bin-runtime-policy.test.ts index e9ee32304056..94cc868c5b28 100644 --- a/src/infra/exec-safe-bin-runtime-policy.test.ts +++ b/src/infra/exec-safe-bin-runtime-policy.test.ts @@ -89,4 +89,18 @@ describe("exec safe-bin runtime policy", () => { expect(policy.trustedSafeBinDirs.has(path.resolve(customDir))).toBe(true); expect(policy.trustedSafeBinDirs.has(path.resolve(agentDir))).toBe(true); }); + + it("does not trust package-manager bin dirs unless explicitly configured", () => { + const defaultPolicy = resolveExecSafeBinRuntimePolicy({}); + expect(defaultPolicy.trustedSafeBinDirs.has(path.resolve("/opt/homebrew/bin"))).toBe(false); + expect(defaultPolicy.trustedSafeBinDirs.has(path.resolve("/usr/local/bin"))).toBe(false); + + const optedIn = resolveExecSafeBinRuntimePolicy({ + global: { + safeBinTrustedDirs: ["/opt/homebrew/bin", "/usr/local/bin"], + }, + }); + expect(optedIn.trustedSafeBinDirs.has(path.resolve("/opt/homebrew/bin"))).toBe(true); + expect(optedIn.trustedSafeBinDirs.has(path.resolve("/usr/local/bin"))).toBe(true); + }); }); diff --git a/src/infra/exec-safe-bin-trust.test.ts b/src/infra/exec-safe-bin-trust.test.ts index f653b13ca7ea..eccd6cce986f 100644 --- a/src/infra/exec-safe-bin-trust.test.ts +++ b/src/infra/exec-safe-bin-trust.test.ts @@ -8,6 +8,15 @@ import { } from "./exec-safe-bin-trust.js"; describe("exec safe bin trust", () => { + it("keeps default trusted dirs limited to immutable system paths", () => { + const dirs = getTrustedSafeBinDirs({ refresh: true }); + + expect(dirs.has(path.resolve("/bin"))).toBe(true); + expect(dirs.has(path.resolve("/usr/bin"))).toBe(true); + expect(dirs.has(path.resolve("/usr/local/bin"))).toBe(false); + expect(dirs.has(path.resolve("/opt/homebrew/bin"))).toBe(false); + }); + it("builds trusted dirs from defaults and explicit extra dirs", () => { const dirs = buildTrustedSafeBinDirs({ baseDirs: ["/usr/bin"], diff --git a/src/infra/exec-safe-bin-trust.ts b/src/infra/exec-safe-bin-trust.ts index 9edfb16a449c..e939ac717115 100644 --- a/src/infra/exec-safe-bin-trust.ts +++ b/src/infra/exec-safe-bin-trust.ts @@ -1,14 +1,8 @@ import path from "node:path"; -const DEFAULT_SAFE_BIN_TRUSTED_DIRS = [ - "/bin", - "/usr/bin", - "/usr/local/bin", - "/opt/homebrew/bin", - "/opt/local/bin", - "/snap/bin", - "/run/current-system/sw/bin", -]; +// Keep defaults to OS-managed immutable bins only. +// User/package-manager bins must be opted in via tools.exec.safeBinTrustedDirs. +const DEFAULT_SAFE_BIN_TRUSTED_DIRS = ["/bin", "/usr/bin"]; type TrustedSafeBinDirsParams = { baseDirs?: readonly string[]; From 270ab03e379f9653e15f7033c9830399b66b7e51 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:16:59 +0000 Subject: [PATCH 251/408] fix: enforce local media root checks for attachment hydration --- CHANGELOG.md | 1 + src/infra/outbound/message-action-params.ts | 27 ++++-- .../outbound/message-action-runner.test.ts | 88 ++++++++++++++----- src/infra/outbound/message-action-runner.ts | 6 ++ 4 files changed, 94 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6315648c4a..5ff29b89bc97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. +- Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. This ships in the next npm release. Thanks @GCXWLP for reporting. - Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index cf230e77417e..e9672841e1cc 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -178,6 +178,8 @@ async function hydrateAttachmentPayload(params: { contentTypeParam?: string | null; mediaHint?: string | null; fileHint?: string | null; + sandboxRoot?: string; + mediaLocalRoots?: readonly string[]; }) { const contentTypeParam = params.contentTypeParam ?? undefined; const rawBuffer = readStringParam(params.args, "buffer", { trim: false }); @@ -201,12 +203,17 @@ async function hydrateAttachmentPayload(params: { channel: params.channel, accountId: params.accountId, }); - // mediaSource already validated by normalizeSandboxMediaList; allow bypass but force explicit readFile. - const media = await loadWebMedia(mediaSource, { - maxBytes, - sandboxValidated: true, - readFile: (filePath: string) => fs.readFile(filePath), - }); + const sandboxRoot = params.sandboxRoot?.trim(); + const media = sandboxRoot + ? await loadWebMedia(mediaSource, { + maxBytes, + sandboxValidated: true, + readFile: (filePath: string) => fs.readFile(filePath), + }) + : await loadWebMedia(mediaSource, { + maxBytes, + localRoots: params.mediaLocalRoots, + }); params.args.buffer = media.buffer.toString("base64"); if (!contentTypeParam && media.contentType) { params.args.contentType = media.contentType; @@ -280,6 +287,8 @@ async function hydrateAttachmentActionPayload(params: { dryRun?: boolean; /** If caption is missing, copy message -> caption. */ allowMessageCaptionFallback?: boolean; + sandboxRoot?: string; + mediaLocalRoots?: readonly string[]; }): Promise { const mediaHint = readStringParam(params.args, "media", { trim: false }); const fileHint = @@ -305,6 +314,8 @@ async function hydrateAttachmentActionPayload(params: { contentTypeParam, mediaHint, fileHint, + sandboxRoot: params.sandboxRoot, + mediaLocalRoots: params.mediaLocalRoots, }); } @@ -315,6 +326,8 @@ export async function hydrateSetGroupIconParams(params: { args: Record; action: ChannelMessageActionName; dryRun?: boolean; + sandboxRoot?: string; + mediaLocalRoots?: readonly string[]; }): Promise { if (params.action !== "setGroupIcon") { return; @@ -329,6 +342,8 @@ export async function hydrateSendAttachmentParams(params: { args: Record; action: ChannelMessageActionName; dryRun?: boolean; + sandboxRoot?: string; + mediaLocalRoots?: readonly string[]; }): Promise { if (params.action !== "sendAttachment") { return; diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index 26591ff23c90..054350a4043f 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -424,6 +424,15 @@ describe("runMessageAction context isolation", () => { }); describe("runMessageAction sendAttachment hydration", () => { + const cfg = { + channels: { + bluebubbles: { + enabled: true, + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + } as OpenClawConfig; const attachmentPlugin: ChannelPlugin = { id: "bluebubbles", meta: { @@ -433,15 +442,15 @@ describe("runMessageAction sendAttachment hydration", () => { docsPath: "/channels/bluebubbles", blurb: "BlueBubbles test plugin.", }, - capabilities: { chatTypes: ["direct"], media: true }, + capabilities: { chatTypes: ["direct", "group"], media: true }, config: { listAccountIds: () => ["default"], resolveAccount: () => ({ enabled: true }), isConfigured: () => true, }, actions: { - listActions: () => ["sendAttachment"], - supportsAction: ({ action }) => action === "sendAttachment", + listActions: () => ["sendAttachment", "setGroupIcon"], + supportsAction: ({ action }) => action === "sendAttachment" || action === "setGroupIcon", handleAction: async ({ params }) => jsonResult({ ok: true, @@ -476,17 +485,12 @@ describe("runMessageAction sendAttachment hydration", () => { vi.clearAllMocks(); }); - it("hydrates buffer and filename from media for sendAttachment", async () => { - const cfg = { - channels: { - bluebubbles: { - enabled: true, - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - } as OpenClawConfig; + async function restoreRealMediaLoader() { + const actual = await vi.importActual("../../web/media.js"); + vi.mocked(loadWebMedia).mockImplementation(actual.loadWebMedia); + } + it("hydrates buffer and filename from media for sendAttachment", async () => { const result = await runMessageAction({ cfg, action: "sendAttachment", @@ -511,15 +515,6 @@ describe("runMessageAction sendAttachment hydration", () => { }); it("rewrites sandboxed media paths for sendAttachment", async () => { - const cfg = { - channels: { - bluebubbles: { - enabled: true, - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - } as OpenClawConfig; await withSandbox(async (sandboxDir) => { await runMessageAction({ cfg, @@ -537,6 +532,55 @@ describe("runMessageAction sendAttachment hydration", () => { expect(call?.[0]).toBe(path.join(sandboxDir, "data", "pic.png")); }); }); + + it("rejects local absolute path for sendAttachment when sandboxRoot is missing", async () => { + await restoreRealMediaLoader(); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-attachment-")); + try { + const outsidePath = path.join(tempDir, "secret.txt"); + await fs.writeFile(outsidePath, "secret", "utf8"); + + await expect( + runMessageAction({ + cfg, + action: "sendAttachment", + params: { + channel: "bluebubbles", + target: "+15551234567", + media: outsidePath, + message: "caption", + }, + }), + ).rejects.toThrow(/allowed directory|path-not-allowed/i); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("rejects local absolute path for setGroupIcon when sandboxRoot is missing", async () => { + await restoreRealMediaLoader(); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-group-icon-")); + try { + const outsidePath = path.join(tempDir, "secret.txt"); + await fs.writeFile(outsidePath, "secret", "utf8"); + + await expect( + runMessageAction({ + cfg, + action: "setGroupIcon", + params: { + channel: "bluebubbles", + target: "group:123", + media: outsidePath, + }, + }), + ).rejects.toThrow(/allowed directory|path-not-allowed/i); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); describe("runMessageAction sandboxed media validation", () => { diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 4095a4993d9e..5031a2cdead7 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -13,6 +13,7 @@ import type { ChannelThreadingToolContext, } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { isDeliverableMessageChannel, normalizeMessageChannel, @@ -757,6 +758,7 @@ export async function runMessageAction( params.accountId = accountId; } const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun")); + const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, resolvedAgentId); await normalizeSandboxMediaParams({ args: params, @@ -770,6 +772,8 @@ export async function runMessageAction( args: params, action, dryRun, + sandboxRoot: input.sandboxRoot, + mediaLocalRoots, }); await hydrateSetGroupIconParams({ @@ -779,6 +783,8 @@ export async function runMessageAction( args: params, action, dryRun, + sandboxRoot: input.sandboxRoot, + mediaLocalRoots, }); const resolvedTarget = await resolveActionTarget({ From 0ee30361b8f6ef3f110f3a7b001da6dd3df96bb5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 22:55:03 +0000 Subject: [PATCH 252/408] fix(synology-chat): fail closed empty allowlist --- docs/channels/synology-chat.md | 1 + extensions/synology-chat/src/channel.test.ts | 19 +++++++++++++++ extensions/synology-chat/src/channel.ts | 5 ++++ extensions/synology-chat/src/security.test.ts | 4 ++-- extensions/synology-chat/src/security.ts | 3 +-- .../synology-chat/src/webhook-handler.test.ts | 20 ++++++++++++++++ .../synology-chat/src/webhook-handler.ts | 24 ++++++++++++------- 7 files changed, 63 insertions(+), 13 deletions(-) diff --git a/docs/channels/synology-chat.md b/docs/channels/synology-chat.md index 78beff43bc40..37c5151f1c9c 100644 --- a/docs/channels/synology-chat.md +++ b/docs/channels/synology-chat.md @@ -72,6 +72,7 @@ Config values override env vars. - `dmPolicy: "allowlist"` is the recommended default. - `allowedUserIds` accepts a list (or comma-separated string) of Synology user IDs. +- In `allowlist` mode, an empty `allowedUserIds` list blocks all senders (use `dmPolicy: "open"` for allow-all). - `dmPolicy: "open"` allows any sender. - `dmPolicy: "disabled"` blocks DMs. - Pairing approvals work with: diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 076339c4456d..080cf4531c97 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -183,6 +183,25 @@ describe("createSynologyChatPlugin", () => { expect(warnings.some((w: string) => w.includes("open"))).toBe(true); }); + it("warns when dmPolicy is allowlist and allowedUserIds is empty", () => { + const plugin = createSynologyChatPlugin(); + const account = { + accountId: "default", + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + nasHost: "h", + webhookPath: "/w", + dmPolicy: "allowlist" as const, + allowedUserIds: [], + rateLimitPerMinute: 30, + botName: "Bot", + allowInsecureSsl: false, + }; + const warnings = plugin.security.collectWarnings({ account }); + expect(warnings.some((w: string) => w.includes("empty allowedUserIds"))).toBe(true); + }); + it("returns no warnings for fully configured account", () => { const plugin = createSynologyChatPlugin(); const account = { diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 37d4a4216ba9..287028abc24d 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -141,6 +141,11 @@ export function createSynologyChatPlugin() { '- Synology Chat: dmPolicy="open" allows any user to message the bot. Consider "allowlist" for production use.', ); } + if (account.dmPolicy === "allowlist" && account.allowedUserIds.length === 0) { + warnings.push( + '- Synology Chat: dmPolicy="allowlist" with empty allowedUserIds blocks all senders. Add users or set dmPolicy="open".', + ); + } return warnings; }, }, diff --git a/extensions/synology-chat/src/security.test.ts b/extensions/synology-chat/src/security.test.ts index 11330dcddc83..7e639da94862 100644 --- a/extensions/synology-chat/src/security.test.ts +++ b/extensions/synology-chat/src/security.test.ts @@ -24,8 +24,8 @@ describe("validateToken", () => { }); describe("checkUserAllowed", () => { - it("allows any user when allowlist is empty", () => { - expect(checkUserAllowed("user1", [])).toBe(true); + it("rejects user when allowlist is empty", () => { + expect(checkUserAllowed("user1", [])).toBe(false); }); it("allows user in the allowlist", () => { diff --git a/extensions/synology-chat/src/security.ts b/extensions/synology-chat/src/security.ts index 43ff054b0779..12336b876b92 100644 --- a/extensions/synology-chat/src/security.ts +++ b/extensions/synology-chat/src/security.ts @@ -22,10 +22,9 @@ export function validateToken(received: string, expected: string): boolean { /** * Check if a user ID is in the allowed list. - * Empty allowlist = allow all users. + * Allowlist mode must be explicit; empty lists should not match any user. */ export function checkUserAllowed(userId: string, allowedUserIds: string[]): boolean { - if (allowedUserIds.length === 0) return true; return allowedUserIds.includes(userId); } diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index 7e20c1006109..1c8ef393ced7 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -156,6 +156,26 @@ describe("createWebhookHandler", () => { }); }); + it("returns 403 when allowlist policy is set with empty allowedUserIds", async () => { + const deliver = vi.fn(); + const handler = createWebhookHandler({ + account: makeAccount({ + dmPolicy: "allowlist", + allowedUserIds: [], + }), + deliver, + log, + }); + + const req = makeReq("POST", validBody); + const res = makeRes(); + await handler(req, res); + + expect(res._status).toBe(403); + expect(res._body).toContain("Allowlist is empty"); + expect(deliver).not.toHaveBeenCalled(); + }); + it("returns 403 when DMs are disabled", async () => { await expectForbiddenByPolicy({ account: { dmPolicy: "disabled" }, diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index d1dae50a6738..0ed5dd844c5b 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -138,20 +138,26 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) { } // User allowlist check - if ( - account.dmPolicy === "allowlist" && - !checkUserAllowed(payload.user_id, account.allowedUserIds) - ) { - log?.warn(`Unauthorized user: ${payload.user_id}`); - respond(res, 403, { error: "User not authorized" }); - return; - } - if (account.dmPolicy === "disabled") { respond(res, 403, { error: "DMs are disabled" }); return; } + if (account.dmPolicy === "allowlist") { + if (account.allowedUserIds.length === 0) { + log?.warn("Synology Chat allowlist is empty while dmPolicy=allowlist; rejecting message"); + respond(res, 403, { + error: "Allowlist is empty. Configure allowedUserIds or use dmPolicy=open.", + }); + return; + } + if (!checkUserAllowed(payload.user_id, account.allowedUserIds)) { + log?.warn(`Unauthorized user: ${payload.user_id}`); + respond(res, 403, { error: "User not authorized" }); + return; + } + } + // Rate limit if (!rateLimiter.check(payload.user_id)) { log?.warn(`Rate limit exceeded for user: ${payload.user_id}`); From 7655c0cb3a47d0647cbbf5284e177f90b4b82ddb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:17:58 +0000 Subject: [PATCH 253/408] docs(changelog): add synology-chat allowlist fail-closed note --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff29b89bc97..e352a5b6f22e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. - Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. This ships in the next npm release. Thanks @GCXWLP for reporting. - Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so `dmPolicy: "allowlist"` with empty `allowedUserIds` rejects all senders instead of allowing unauthorized dispatch. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), preventing writable-dir binary shadowing from auto-satisfying safe-bin allowlist checks. This ships in the next npm release. Thanks @tdjackey for reporting. From ccbeb332e096c1d0f64321379886bc2ebbe1d2ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:13:51 +0000 Subject: [PATCH 254/408] fix: harden routing/session isolation for followups and heartbeat --- CHANGELOG.md | 3 + .../reply/agent-runner-payloads.test.ts | 14 +++++ src/auto-reply/reply/agent-runner-payloads.ts | 3 +- .../reply/agent-runner-utils.test.ts | 18 ++++++ src/auto-reply/reply/agent-runner-utils.ts | 5 +- src/auto-reply/reply/agent-runner.ts | 1 + src/auto-reply/reply/followup-runner.test.ts | 59 ++++++++++++++++++- src/auto-reply/reply/followup-runner.ts | 11 ++-- src/auto-reply/reply/get-reply-run.ts | 5 +- src/auto-reply/reply/queue/drain.ts | 6 +- src/auto-reply/reply/reply-flow.test.ts | 45 ++++++++++++++ ...tbeat-runner.returns-default-unset.test.ts | 2 + src/infra/heartbeat-runner.ts | 4 ++ src/infra/outbound/targets.test.ts | 43 +++++++++++++- src/infra/outbound/targets.ts | 5 +- 15 files changed, 209 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e352a5b6f22e..fcbd4cb1e563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ Docs: https://docs.openclaw.ai ### Fixes +- Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. +- Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch. +- Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. - Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. - Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. This ships in the next npm release. Thanks @GCXWLP for reporting. diff --git a/src/auto-reply/reply/agent-runner-payloads.test.ts b/src/auto-reply/reply/agent-runner-payloads.test.ts index 88f7d41a4c93..40d0ae72ad3e 100644 --- a/src/auto-reply/reply/agent-runner-payloads.test.ts +++ b/src/auto-reply/reply/agent-runner-payloads.test.ts @@ -71,4 +71,18 @@ describe("buildReplyPayloads media filter integration", () => { expect(replyPayloads).toHaveLength(1); expect(replyPayloads[0]?.mediaUrl).toBe("file:///tmp/photo.jpg"); }); + + it("suppresses same-target replies when messageProvider is synthetic but originatingChannel is set", () => { + const { replyPayloads } = buildReplyPayloads({ + ...baseParams, + payloads: [{ text: "hello world!" }], + messageProvider: "heartbeat", + originatingChannel: "telegram", + originatingTo: "268300329", + messagingToolSentTexts: ["different message"], + messagingToolSentTargets: [{ tool: "telegram", provider: "telegram", to: "268300329" }], + }); + + expect(replyPayloads).toHaveLength(0); + }); }); diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index a1de8c1d163d..6aaa93aa633e 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -32,6 +32,7 @@ export function buildReplyPayloads(params: { messagingToolSentTargets?: Parameters< typeof shouldSuppressMessagingToolReplies >[0]["messagingToolSentTargets"]; + originatingChannel?: OriginatingChannelType; originatingTo?: string; accountId?: string; }): { replyPayloads: ReplyPayload[]; didLogHeartbeatStrip: boolean } { @@ -86,7 +87,7 @@ export function buildReplyPayloads(params: { const messagingToolSentTexts = params.messagingToolSentTexts ?? []; const messagingToolSentTargets = params.messagingToolSentTargets ?? []; const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({ - messageProvider: params.messageProvider, + messageProvider: params.originatingChannel ?? params.messageProvider, messagingToolSentTargets, originatingTo: params.originatingTo, accountId: params.accountId, diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 0650f5d65200..397cefcb82ae 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -149,4 +149,22 @@ describe("agent-runner-utils", () => { senderE164: undefined, }); }); + + it("prefers OriginatingChannel over Provider for messageProvider", () => { + const run = makeRun(); + + const resolved = buildEmbeddedRunContexts({ + run, + sessionCtx: { + Provider: "heartbeat", + OriginatingChannel: "Telegram", + OriginatingTo: "268300329", + }, + hasRepliedRef: undefined, + provider: "openai", + }); + + expect(resolved.embeddedContext.messageProvider).toBe("telegram"); + expect(resolved.embeddedContext.messageTo).toBe("268300329"); + }); }); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 58cf1951227d..c3d09877a4dd 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -196,7 +196,10 @@ export function buildEmbeddedContextFromTemplate(params: { sessionId: params.run.sessionId, sessionKey: params.run.sessionKey, agentId: params.run.agentId, - messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined, + messageProvider: + params.sessionCtx.OriginatingChannel?.trim().toLowerCase() || + params.sessionCtx.Provider?.trim().toLowerCase() || + undefined, agentAccountId: params.sessionCtx.AccountId, messageTo: params.sessionCtx.OriginatingTo ?? params.sessionCtx.To, messageThreadId: params.sessionCtx.MessageThreadId ?? undefined, diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index b00dcd969f8a..e3f47246e7ca 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -514,6 +514,7 @@ export async function runReplyAgent(params: { messagingToolSentTexts: runResult.messagingToolSentTexts, messagingToolSentMediaUrls: runResult.messagingToolSentMediaUrls, messagingToolSentTargets: runResult.messagingToolSentTargets, + originatingChannel: sessionCtx.OriginatingChannel, originatingTo: sessionCtx.OriginatingTo ?? sessionCtx.To, accountId: sessionCtx.AccountId, }); diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 0da9b1ff76d2..a77bb0be44e1 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -1,12 +1,13 @@ import fs from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { loadSessionStore, saveSessionStore, type SessionEntry } from "../../config/sessions.js"; import type { FollowupRun } from "./queue.js"; import { createMockTypingController } from "./test-helpers.js"; const runEmbeddedPiAgentMock = vi.fn(); +const routeReplyMock = vi.fn(); vi.mock( "../../agents/model-fallback.js", @@ -17,8 +18,21 @@ vi.mock("../../agents/pi-embedded.js", () => ({ runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params), })); +vi.mock("./route-reply.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + routeReply: (...args: unknown[]) => routeReplyMock(...args), + }; +}); + import { createFollowupRunner } from "./followup-runner.js"; +beforeEach(() => { + routeReplyMock.mockReset(); + routeReplyMock.mockResolvedValue({ ok: true }); +}); + const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun => ({ prompt: "hello", @@ -204,6 +218,26 @@ describe("createFollowupRunner messaging tool dedupe", () => { expect(onBlockReply).not.toHaveBeenCalled(); }); + it("suppresses replies when provider is synthetic but originating channel matches", async () => { + const onBlockReply = vi.fn(async () => {}); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "hello world!" }], + messagingToolSentTexts: ["different message"], + messagingToolSentTargets: [{ tool: "telegram", provider: "telegram", to: "268300329" }], + meta: {}, + }); + + const runner = createMessagingDedupeRunner(onBlockReply); + + await runner({ + ...baseQueuedRun("heartbeat"), + originatingChannel: "telegram", + originatingTo: "268300329", + } as FollowupRun); + + expect(onBlockReply).not.toHaveBeenCalled(); + }); + it("drops media URL from payload when messaging tool already sent it", async () => { const onBlockReply = vi.fn(async () => {}); runEmbeddedPiAgentMock.mockResolvedValueOnce({ @@ -278,6 +312,29 @@ describe("createFollowupRunner messaging tool dedupe", () => { expect(store[sessionKey]?.inputTokens).toBe(1_000); expect(store[sessionKey]?.outputTokens).toBe(50); }); + + it("does not fall back to dispatcher when explicit origin routing fails", async () => { + const onBlockReply = vi.fn(async () => {}); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "hello world!" }], + meta: {}, + }); + routeReplyMock.mockResolvedValueOnce({ + ok: false, + error: "forced route failure", + }); + + const runner = createMessagingDedupeRunner(onBlockReply); + + await runner({ + ...baseQueuedRun("webchat"), + originatingChannel: "discord", + originatingTo: "channel:C1", + } as FollowupRun); + + expect(routeReplyMock).toHaveBeenCalled(); + expect(onBlockReply).not.toHaveBeenCalled(); + }); }); describe("createFollowupRunner agentDir forwarding", () => { diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index cdae8d014af5..5c0ec491f564 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -98,13 +98,10 @@ export function createFollowupRunner(params: { cfg: queued.run.config, }); if (!result.ok) { - // Log error and fall back to dispatcher if available. + // Keep origin isolation strict: do not fall back to the current + // dispatcher when explicit origin routing failed. const errorMsg = result.error ?? "unknown error"; logVerbose(`followup queue: route-reply failed: ${errorMsg}`); - // Fallback: try the dispatcher if routing failed. - if (opts?.onBlockReply) { - await opts.onBlockReply(payload); - } } } else if (opts?.onBlockReply) { await opts.onBlockReply(payload); @@ -259,10 +256,10 @@ export function createFollowupRunner(params: { sentMediaUrls: runResult.messagingToolSentMediaUrls ?? [], }); const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({ - messageProvider: queued.run.messageProvider, + messageProvider: queued.originatingChannel ?? queued.run.messageProvider, messagingToolSentTargets: runResult.messagingToolSentTargets, originatingTo: queued.originatingTo, - accountId: queued.run.agentAccountId, + accountId: queued.originatingAccountId ?? queued.run.agentAccountId, }); const finalPayloads = suppressMessagingToolReplies ? [] : mediaFilteredPayloads; diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 85f657b48159..edcf15b31c70 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -460,7 +460,10 @@ export async function runPreparedReply( agentDir, sessionId: sessionIdFinal, sessionKey, - messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined, + messageProvider: + sessionCtx.OriginatingChannel?.trim().toLowerCase() || + sessionCtx.Provider?.trim().toLowerCase() || + undefined, agentAccountId: sessionCtx.AccountId, groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined, groupChannel: sessionCtx.GroupChannel?.trim() ?? sessionCtx.GroupSubject?.trim(), diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index 75e6ffa07d8a..b37c2b01f158 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -111,11 +111,15 @@ export function scheduleFollowupDrain( break; } if ( - !(await drainNextQueueItem(queue.items, async () => { + !(await drainNextQueueItem(queue.items, async (item) => { await runFollowup({ prompt: summaryPrompt, run, enqueuedAt: Date.now(), + originatingChannel: item.originatingChannel, + originatingTo: item.originatingTo, + originatingAccountId: item.originatingAccountId, + originatingThreadId: item.originatingThreadId, }); })) ) { diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index 3f79e3e68033..3b91cf52d40e 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -1046,6 +1046,51 @@ describe("followup queue collect routing", () => { expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); expect(calls[0]?.prompt).toContain("- first"); }); + + it("preserves routing metadata on overflow summary followups", async () => { + const key = `test-overflow-summary-routing-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + done.resolve(); + }; + const settings: QueueSettings = { + mode: "followup", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "first", + originatingChannel: "discord", + originatingTo: "channel:C1", + originatingThreadId: "1739142736.000100", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "second", + originatingChannel: "discord", + originatingTo: "channel:C1", + originatingThreadId: "1739142736.000100", + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + + expect(calls[0]?.originatingChannel).toBe("discord"); + expect(calls[0]?.originatingTo).toBe("channel:C1"); + expect(calls[0]?.originatingThreadId).toBe("1739142736.000100"); + expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); + }); }); const emptyCfg = {} as OpenClawConfig; diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index d0d34a7bd759..908f45ebb522 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -591,6 +591,8 @@ describe("runHeartbeatOnce", () => { SessionKey: sessionKey, From: "+1555", To: "+1555", + OriginatingChannel: "whatsapp", + OriginatingTo: "+1555", Provider: "heartbeat", }), expect.objectContaining({ isHeartbeat: true, suppressToolErrorWarnings: false }), diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index a34ccfdb7e39..ad2c091f1562 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -663,6 +663,10 @@ export async function runHeartbeatOnce(opts: { Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt), From: sender, To: sender, + OriginatingChannel: delivery.channel !== "none" ? delivery.channel : undefined, + OriginatingTo: delivery.to, + AccountId: delivery.accountId, + MessageThreadId: delivery.threadId, Provider: hasExecCompletion ? "exec-event" : hasCronEvents ? "cron-event" : "heartbeat", SessionKey: sessionKey, }; diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index ac9fa08b1e72..9d7ec7dde5ec 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { resolveOutboundTarget, resolveSessionDeliveryTarget } from "./targets.js"; +import { + resolveHeartbeatDeliveryTarget, + resolveOutboundTarget, + resolveSessionDeliveryTarget, +} from "./targets.js"; import { installResolveOutboundTargetPluginRegistryHooks, runResolveOutboundTargetCoreTests, @@ -175,6 +179,22 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.threadId).toBe(999); }); + it("does not inherit lastThreadId in heartbeat mode", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-heartbeat-thread", + updatedAt: 1, + lastChannel: "slack", + lastTo: "user:U123", + lastThreadId: "1739142736.000100", + }, + requestedChannel: "last", + mode: "heartbeat", + }); + + expect(resolved.threadId).toBeUndefined(); + }); + it("falls back to a provided channel when requested is unsupported", () => { const resolved = resolveSessionDeliveryTarget({ entry: { @@ -280,4 +300,25 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.threadId).toBe(42); expect(resolved.to).toBe("63448508"); }); + + it("does not return inherited threadId from resolveHeartbeatDeliveryTarget", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-outbound", + updatedAt: 1, + lastChannel: "slack", + lastTo: "user:U123", + lastThreadId: "1739142736.000100", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("slack"); + expect(resolved.to).toBe("user:U123"); + expect(resolved.threadId).toBeUndefined(); + }); }); diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 608e62c6005c..8a33353bb5a9 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -115,9 +115,10 @@ export function resolveSessionDeliveryTarget(params: { } } - const accountId = channel && channel === lastChannel ? lastAccountId : undefined; - const threadId = channel && channel === lastChannel ? lastThreadId : undefined; const mode = params.mode ?? (explicitTo ? "explicit" : "implicit"); + const accountId = channel && channel === lastChannel ? lastAccountId : undefined; + const threadId = + mode !== "heartbeat" && channel && channel === lastChannel ? lastThreadId : undefined; const resolvedThreadId = explicitThreadId ?? threadId; return { From 14b6eea6e30e805e2147db900a301f074dade96e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:19:48 +0000 Subject: [PATCH 255/408] feat(sandbox): block container namespace joins by default --- CHANGELOG.md | 4 ++ docs/cli/security.md | 1 + docs/gateway/configuration-reference.md | 4 +- docs/gateway/sandboxing.md | 7 ++ docs/gateway/security/index.md | 3 + docs/install/docker.md | 8 ++- src/agents/sandbox-create-args.test.ts | 20 ++++++ src/agents/sandbox/browser.ts | 20 +++--- src/agents/sandbox/config.ts | 9 +++ src/agents/sandbox/docker.ts | 4 ++ .../sandbox/validate-sandbox-security.test.ts | 24 +++++++ .../sandbox/validate-sandbox-security.ts | 29 ++++++++- src/config/config.sandbox-docker.test.ts | 64 +++++++++++++++++++ src/config/types.sandbox.ts | 5 ++ src/config/zod-schema.agent-runtime.ts | 28 +++++++- src/security/audit-extra.sync.ts | 16 +++-- src/security/audit.test.ts | 25 ++++++++ 17 files changed, 253 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcbd4cb1e563..62beb85b6948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ Docs: https://docs.openclaw.ai - Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), and add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms) so emergency stop messages are caught more reliably. (#25103) Thanks @steipete and @vincentkoc. - Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes). +### Breaking + +- **BREAKING:** Security/Sandbox: block Docker `network: "container:"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting. + ### Fixes - Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. diff --git a/docs/cli/security.md b/docs/cli/security.md index b962ebef675d..fe8af41ec259 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -32,6 +32,7 @@ For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when requ It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed extension plugin tools may be reachable under permissive tool policy. It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxies are misconfigured) and `discovery.mdns.mode="full"` (metadata leakage via mDNS TXT records). It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`. +It also flags dangerous sandbox Docker network modes (including `host` and `container:*` namespace joins). It also warns when existing sandbox browser Docker containers have missing/stale hash labels (for example pre-migration containers missing `openclaw.browserConfigEpoch`) and recommends `openclaw sandbox recreate --browser --all`. It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions. It warns when channel allowlists rely on mutable names/emails/tags instead of stable IDs (Discord, Slack, Google Chat, MS Teams, Mattermost, IRC scopes where applicable). diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 0b89a272d903..825acbaadf5a 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1017,7 +1017,9 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway **`setupCommand`** runs once after container creation (via `sh -lc`). Needs network egress, writable root, root user. -**Containers default to `network: "none"`** — set to `"bridge"` if the agent needs outbound access. +**Containers default to `network: "none"`** — set to `"bridge"` (or a custom bridge network) if the agent needs outbound access. +`"host"` is blocked. `"container:"` is blocked by default unless you explicitly set +`sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). **Inbound attachments** are staged into `media/inbound/*` in the active workspace. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 6d51f573990a..8be57bd10642 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -138,6 +138,12 @@ scripts/sandbox-browser-setup.sh By default, sandbox containers run with **no network**. Override with `agents.defaults.sandbox.docker.network`. +Security defaults: + +- `network: "host"` is blocked. +- `network: "container:"` is blocked by default (namespace join bypass risk). +- Break-glass override: `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true`. + Docker installs and the containerized gateway live here: [Docker](/install/docker) @@ -154,6 +160,7 @@ Paths: Common pitfalls: - Default `docker.network` is `"none"` (no egress), so package installs will fail. +- `docker.network: "container:"` requires `dangerouslyAllowContainerNamespaceJoin: true` and is break-glass only. - `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image. - `user` must be root for package installs (omit `user` or set `user: "0:0"`). - Sandbox exec does **not** inherit host `process.env`. Use diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index c0d642b0e55e..c9c3f4051e4a 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -244,6 +244,7 @@ High-signal `checkId` values you will most likely see in real deployments (not e | `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no | | `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes | | `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no | +| `sandbox.dangerous_network_mode` | critical | Sandbox Docker network uses `host` or `container:*` namespace-join mode | `agents.*.sandbox.docker.network` | no | | `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no | | `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no | | `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no | @@ -299,8 +300,10 @@ schema: - `channels.mattermost.accounts..dangerouslyAllowNameMatching` (extension channel) - `agents.defaults.sandbox.docker.dangerouslyAllowReservedContainerTargets` - `agents.defaults.sandbox.docker.dangerouslyAllowExternalBindSources` +- `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin` - `agents.list[].sandbox.docker.dangerouslyAllowReservedContainerTargets` - `agents.list[].sandbox.docker.dangerouslyAllowExternalBindSources` +- `agents.list[].sandbox.docker.dangerouslyAllowContainerNamespaceJoin` ## Reverse Proxy Configuration diff --git a/docs/install/docker.md b/docs/install/docker.md index 8826192c1c13..decd1d779ee7 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -368,6 +368,8 @@ precedence, and troubleshooting. - `"rw"` mounts the agent workspace read/write at `/workspace` - Auto-prune: idle > 24h OR age > 7d - Network: `none` by default (explicitly opt-in if you need egress) + - `host` is blocked. + - `container:` is blocked by default (namespace-join risk). - Default allow: `exec`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` - Default deny: `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway` @@ -376,6 +378,9 @@ precedence, and troubleshooting. If you plan to install packages in `setupCommand`, note: - Default `docker.network` is `"none"` (no egress). +- `docker.network: "host"` is blocked. +- `docker.network: "container:"` is blocked by default. +- Break-glass override: `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true`. - `readOnlyRoot: true` blocks package installs. - `user` must be root for `apt-get` (omit `user` or set `user: "0:0"`). OpenClaw auto-recreates containers when `setupCommand` (or docker config) changes @@ -445,7 +450,8 @@ If you plan to install packages in `setupCommand`, note: Hardening knobs live under `agents.defaults.sandbox.docker`: `network`, `user`, `pidsLimit`, `memory`, `memorySwap`, `cpus`, `ulimits`, -`seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`. +`seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`, +`dangerouslyAllowContainerNamespaceJoin` (break-glass only). Multi-agent: override `agents.defaults.sandbox.{docker,browser,prune}.*` per agent via `agents.list[].sandbox.{docker,browser,prune}.*` (ignored when `agents.defaults.sandbox.scope` / `agents.list[].sandbox.scope` is `"shared"`). diff --git a/src/agents/sandbox-create-args.test.ts b/src/agents/sandbox-create-args.test.ts index 2347b88fc3ed..9bc005471439 100644 --- a/src/agents/sandbox-create-args.test.ts +++ b/src/agents/sandbox-create-args.test.ts @@ -181,6 +181,12 @@ describe("buildSandboxCreateArgs", () => { cfg: createSandboxConfig({ network: "host" }), expected: /network mode "host" is blocked/, }, + { + name: "network container namespace join", + containerName: "openclaw-sbx-container-network", + cfg: createSandboxConfig({ network: "container:peer" }), + expected: /network mode "container:peer" is blocked by default/, + }, { name: "seccomp unconfined", containerName: "openclaw-sbx-seccomp", @@ -271,4 +277,18 @@ describe("buildSandboxCreateArgs", () => { }); expect(args).toEqual(expect.arrayContaining(["-v", "/tmp/override:/workspace:rw"])); }); + + it("allows container namespace join with explicit dangerous override", () => { + const cfg = createSandboxConfig({ + network: "container:peer", + dangerouslyAllowContainerNamespaceJoin: true, + }); + const args = buildSandboxCreateArgs({ + name: "openclaw-sbx-container-network-override", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }); + expect(args).toEqual(expect.arrayContaining(["--network", "container:peer"])); + }); }); diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index f96261bfab71..c4459b19bdd2 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -36,6 +36,7 @@ import { readBrowserRegistry, updateBrowserRegistry } from "./registry.js"; import { resolveSandboxAgentId, slugifySessionKey } from "./shared.js"; import { isToolAllowed } from "./tool-policy.js"; import type { SandboxBrowserContext, SandboxConfig } from "./types.js"; +import { validateNetworkMode } from "./validate-sandbox-security.js"; const HOT_BROWSER_WINDOW_MS = 5 * 60 * 1000; const CDP_SOURCE_RANGE_ENV_KEY = "OPENCLAW_BROWSER_CDP_SOURCE_RANGE"; @@ -107,14 +108,15 @@ async function ensureSandboxBrowserImage(image: string) { ); } -async function ensureDockerNetwork(network: string) { +async function ensureDockerNetwork( + network: string, + opts?: { allowContainerNamespaceJoin?: boolean }, +) { + validateNetworkMode(network, { + allowContainerNamespaceJoin: opts?.allowContainerNamespaceJoin === true, + }); const normalized = network.trim().toLowerCase(); - if ( - !normalized || - normalized === "bridge" || - normalized === "none" || - normalized.startsWith("container:") - ) { + if (!normalized || normalized === "bridge" || normalized === "none") { return; } const inspect = await execDocker(["network", "inspect", network], { allowFailure: true }); @@ -216,7 +218,9 @@ export async function ensureSandboxBrowser(params: { if (noVncEnabled) { noVncPassword = generateNoVncPassword(); } - await ensureDockerNetwork(browserDockerCfg.network); + await ensureDockerNetwork(browserDockerCfg.network, { + allowContainerNamespaceJoin: browserDockerCfg.dangerouslyAllowContainerNamespaceJoin === true, + }); await ensureSandboxBrowserImage(browserImage); const args = buildSandboxCreateArgs({ name: containerName, diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index 0fcb50999e42..135c9a6520b4 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -95,6 +95,15 @@ export function resolveSandboxDockerConfig(params: { dns: agentDocker?.dns ?? globalDocker?.dns, extraHosts: agentDocker?.extraHosts ?? globalDocker?.extraHosts, binds: binds.length ? binds : undefined, + dangerouslyAllowReservedContainerTargets: + agentDocker?.dangerouslyAllowReservedContainerTargets ?? + globalDocker?.dangerouslyAllowReservedContainerTargets, + dangerouslyAllowExternalBindSources: + agentDocker?.dangerouslyAllowExternalBindSources ?? + globalDocker?.dangerouslyAllowExternalBindSources, + dangerouslyAllowContainerNamespaceJoin: + agentDocker?.dangerouslyAllowContainerNamespaceJoin ?? + globalDocker?.dangerouslyAllowContainerNamespaceJoin, }; } diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 270e8b761d43..efaa4b0e22e6 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -267,6 +267,7 @@ export function buildSandboxCreateArgs(params: { bindSourceRoots?: string[]; allowSourcesOutsideAllowedRoots?: boolean; allowReservedContainerTargets?: boolean; + allowContainerNamespaceJoin?: boolean; }) { // Runtime security validation: blocks dangerous bind mounts, network modes, and profiles. validateSandboxSecurity({ @@ -278,6 +279,9 @@ export function buildSandboxCreateArgs(params: { allowReservedContainerTargets: params.allowReservedContainerTargets ?? params.cfg.dangerouslyAllowReservedContainerTargets === true, + dangerouslyAllowContainerNamespaceJoin: + params.allowContainerNamespaceJoin ?? + params.cfg.dangerouslyAllowContainerNamespaceJoin === true, }); const createdAtMs = params.createdAtMs ?? Date.now(); diff --git a/src/agents/sandbox/validate-sandbox-security.test.ts b/src/agents/sandbox/validate-sandbox-security.test.ts index 22a5be14d5da..cc3bd2e00a70 100644 --- a/src/agents/sandbox/validate-sandbox-security.test.ts +++ b/src/agents/sandbox/validate-sandbox-security.test.ts @@ -222,6 +222,30 @@ describe("validateNetworkMode", () => { expect(() => validateNetworkMode(testCase.mode), testCase.mode).toThrow(testCase.expected); } }); + + it("blocks container namespace joins by default", () => { + const cases = [ + { + mode: "container:abc123", + expected: /network mode "container:abc123" is blocked by default/, + }, + { + mode: "CONTAINER:ABC123", + expected: /network mode "CONTAINER:ABC123" is blocked by default/, + }, + ] as const; + for (const testCase of cases) { + expect(() => validateNetworkMode(testCase.mode), testCase.mode).toThrow(testCase.expected); + } + }); + + it("allows container namespace joins with explicit dangerous override", () => { + expect(() => + validateNetworkMode("container:abc123", { + allowContainerNamespaceJoin: true, + }), + ).not.toThrow(); + }); }); describe("validateSeccompProfile", () => { diff --git a/src/agents/sandbox/validate-sandbox-security.ts b/src/agents/sandbox/validate-sandbox-security.ts index 393d9f4b3361..928459836c4d 100644 --- a/src/agents/sandbox/validate-sandbox-security.ts +++ b/src/agents/sandbox/validate-sandbox-security.ts @@ -42,6 +42,10 @@ export type ValidateBindMountsOptions = { allowReservedContainerTargets?: boolean; }; +export type ValidateNetworkModeOptions = { + allowContainerNamespaceJoin?: boolean; +}; + export type BlockedBindReason = | { kind: "targets"; blockedPath: string } | { kind: "covers"; blockedPath: string } @@ -276,14 +280,30 @@ export function validateBindMounts( } } -export function validateNetworkMode(network: string | undefined): void { - if (network && BLOCKED_NETWORK_MODES.has(network.trim().toLowerCase())) { +export function validateNetworkMode( + network: string | undefined, + options?: ValidateNetworkModeOptions, +): void { + const normalized = network?.trim().toLowerCase(); + if (!normalized) { + return; + } + + if (BLOCKED_NETWORK_MODES.has(normalized)) { throw new Error( `Sandbox security: network mode "${network}" is blocked. ` + 'Network "host" mode bypasses container network isolation. ' + 'Use "bridge" or "none" instead.', ); } + + if (normalized.startsWith("container:") && options?.allowContainerNamespaceJoin !== true) { + throw new Error( + `Sandbox security: network mode "${network}" is blocked by default. ` + + 'Network "container:*" joins another container namespace and bypasses sandbox network isolation. ' + + "Use a custom bridge network, or set dangerouslyAllowContainerNamespaceJoin=true only when you fully trust this runtime.", + ); + } } export function validateSeccompProfile(profile: string | undefined): void { @@ -312,10 +332,13 @@ export function validateSandboxSecurity( network?: string; seccompProfile?: string; apparmorProfile?: string; + dangerouslyAllowContainerNamespaceJoin?: boolean; } & ValidateBindMountsOptions, ): void { validateBindMounts(cfg.binds, cfg); - validateNetworkMode(cfg.network); + validateNetworkMode(cfg.network, { + allowContainerNamespaceJoin: cfg.dangerouslyAllowContainerNamespaceJoin === true, + }); validateSeccompProfile(cfg.seccompProfile); validateApparmorProfile(cfg.apparmorProfile); } diff --git a/src/config/config.sandbox-docker.test.ts b/src/config/config.sandbox-docker.test.ts index d7c3cd286a09..032a68e857b8 100644 --- a/src/config/config.sandbox-docker.test.ts +++ b/src/config/config.sandbox-docker.test.ts @@ -53,6 +53,37 @@ describe("sandbox docker config", () => { expect(res.ok).toBe(false); }); + it("rejects container namespace join by default", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + network: "container:peer", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); + + it("allows container namespace join with explicit dangerous override", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + network: "container:peer", + dangerouslyAllowContainerNamespaceJoin: true, + }, + }, + }, + }, + }); + expect(res.ok).toBe(true); + }); + it("rejects seccomp unconfined via Zod schema validation", () => { const res = validateConfigObject({ agents: { @@ -219,4 +250,37 @@ describe("sandbox browser binds config", () => { }); expect(res.ok).toBe(false); }); + + it("rejects container namespace join in sandbox.browser config by default", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + browser: { + network: "container:peer", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); + + it("allows container namespace join in sandbox.browser config with explicit dangerous override", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + dangerouslyAllowContainerNamespaceJoin: true, + }, + browser: { + network: "container:peer", + }, + }, + }, + }, + }); + expect(res.ok).toBe(true); + }); }); diff --git a/src/config/types.sandbox.ts b/src/config/types.sandbox.ts index 0d7ecfc8a970..b4d5e6e20270 100644 --- a/src/config/types.sandbox.ts +++ b/src/config/types.sandbox.ts @@ -52,6 +52,11 @@ export type SandboxDockerSettings = { * (workspace + agent workspace roots). */ dangerouslyAllowExternalBindSources?: boolean; + /** + * Dangerous override: allow Docker `network: "container:"` namespace joins. + * Default behavior blocks container namespace joins to preserve sandbox isolation. + */ + dangerouslyAllowContainerNamespaceJoin?: boolean; }; export type SandboxBrowserSettings = { diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 5147ba576ece..ca559ce5e941 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -126,6 +126,7 @@ export const SandboxDockerSchema = z binds: z.array(z.string()).optional(), dangerouslyAllowReservedContainerTargets: z.boolean().optional(), dangerouslyAllowExternalBindSources: z.boolean().optional(), + dangerouslyAllowContainerNamespaceJoin: z.boolean().optional(), }) .strict() .superRefine((data, ctx) => { @@ -153,7 +154,8 @@ export const SandboxDockerSchema = z } } } - if (data.network?.trim().toLowerCase() === "host") { + const network = data.network?.trim().toLowerCase(); + if (network === "host") { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["network"], @@ -161,6 +163,15 @@ export const SandboxDockerSchema = z 'Sandbox security: network mode "host" is blocked. Use "bridge" or "none" instead.', }); } + if (network?.startsWith("container:") && data.dangerouslyAllowContainerNamespaceJoin !== true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["network"], + message: + 'Sandbox security: network mode "container:*" is blocked by default. ' + + "Use a custom bridge network, or set dangerouslyAllowContainerNamespaceJoin=true only when you fully trust this runtime.", + }); + } if (data.seccompProfile?.trim().toLowerCase() === "unconfined") { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -464,6 +475,21 @@ export const AgentSandboxSchema = z prune: SandboxPruneSchema, }) .strict() + .superRefine((data, ctx) => { + const browserNetwork = data.browser?.network?.trim().toLowerCase(); + if ( + browserNetwork?.startsWith("container:") && + data.docker?.dangerouslyAllowContainerNamespaceJoin !== true + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["browser", "network"], + message: + 'Sandbox security: browser network mode "container:*" is blocked by default. ' + + "Set sandbox.docker.dangerouslyAllowContainerNamespaceJoin=true only when you fully trust this runtime.", + }); + } + }) .optional(); const CommonToolPolicyFields = { diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index 464930d91268..893d1afb8a05 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -830,13 +830,21 @@ export function collectSandboxDangerousConfigFindings(cfg: OpenClawConfig): Secu } const network = typeof docker.network === "string" ? docker.network : undefined; - if (network && network.trim().toLowerCase() === "host") { + const normalizedNetwork = network?.trim().toLowerCase(); + if (normalizedNetwork === "host" || normalizedNetwork?.startsWith("container:")) { + const modeLabel = normalizedNetwork === "host" ? '"host"' : `"${network}"`; + const detail = + normalizedNetwork === "host" + ? `${source}.network is "host" which bypasses container network isolation entirely.` + : `${source}.network is ${modeLabel} which joins another container namespace and can bypass sandbox network isolation.`; findings.push({ checkId: "sandbox.dangerous_network_mode", severity: "critical", - title: "Network host mode in sandbox config", - detail: `${source}.network is "host" which bypasses container network isolation entirely.`, - remediation: `Set ${source}.network to "bridge" or "none".`, + title: "Dangerous network mode in sandbox config", + detail, + remediation: + `Set ${source}.network to "bridge", "none", or a custom bridge network name.` + + ` Use ${source}.dangerouslyAllowContainerNamespaceJoin=true only as a break-glass override when you fully trust this runtime.`, }); } diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 3b7d54fcb8d2..4354c32b77be 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -855,6 +855,31 @@ describe("security audit", () => { ); }); + it("flags container namespace join network mode in sandbox config", async () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "all", + docker: { + network: "container:peer", + }, + }, + }, + }, + }; + const res = await audit(cfg); + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "sandbox.dangerous_network_mode", + severity: "critical", + title: "Dangerous network mode in sandbox config", + }), + ]), + ); + }); + it("checks sandbox browser bridge-network restrictions", async () => { const cases: Array<{ name: string; From 5552f9073fbdbec3923feed1a5a2a919062d48c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:26:46 +0000 Subject: [PATCH 256/408] refactor(sandbox): centralize network mode policy helpers --- src/agents/sandbox/network-mode.ts | 28 +++++++++++++++++++ .../sandbox/validate-sandbox-security.ts | 15 +++++----- src/config/config.sandbox-docker.test.ts | 21 +++++++++++++- src/config/schema.help.ts | 4 +++ src/config/schema.labels.ts | 4 +++ src/config/zod-schema.agent-runtime.ts | 20 +++++++------ src/security/audit-extra.sync.ts | 5 ++-- 7 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 src/agents/sandbox/network-mode.ts diff --git a/src/agents/sandbox/network-mode.ts b/src/agents/sandbox/network-mode.ts new file mode 100644 index 000000000000..6fe5ee6ac828 --- /dev/null +++ b/src/agents/sandbox/network-mode.ts @@ -0,0 +1,28 @@ +export type NetworkModeBlockReason = "host" | "container_namespace_join"; + +export function normalizeNetworkMode(network: string | undefined): string | undefined { + const normalized = network?.trim().toLowerCase(); + return normalized || undefined; +} + +export function getBlockedNetworkModeReason(params: { + network: string | undefined; + allowContainerNamespaceJoin?: boolean; +}): NetworkModeBlockReason | null { + const normalized = normalizeNetworkMode(params.network); + if (!normalized) { + return null; + } + if (normalized === "host") { + return "host"; + } + if (normalized.startsWith("container:") && params.allowContainerNamespaceJoin !== true) { + return "container_namespace_join"; + } + return null; +} + +export function isDangerousNetworkMode(network: string | undefined): boolean { + const normalized = normalizeNetworkMode(network); + return normalized === "host" || normalized?.startsWith("container:") === true; +} diff --git a/src/agents/sandbox/validate-sandbox-security.ts b/src/agents/sandbox/validate-sandbox-security.ts index 928459836c4d..097f883f9882 100644 --- a/src/agents/sandbox/validate-sandbox-security.ts +++ b/src/agents/sandbox/validate-sandbox-security.ts @@ -11,6 +11,7 @@ import { normalizeSandboxHostPath, resolveSandboxHostPathViaExistingAncestor, } from "./host-paths.js"; +import { getBlockedNetworkModeReason } from "./network-mode.js"; // Targeted denylist: host paths that should never be exposed inside sandbox containers. // Exported for reuse in security audit collectors. @@ -31,7 +32,6 @@ export const BLOCKED_HOST_PATHS = [ "/run/docker.sock", ]; -const BLOCKED_NETWORK_MODES = new Set(["host"]); const BLOCKED_SECCOMP_PROFILES = new Set(["unconfined"]); const BLOCKED_APPARMOR_PROFILES = new Set(["unconfined"]); const RESERVED_CONTAINER_TARGET_PATHS = ["/workspace", SANDBOX_AGENT_WORKSPACE_MOUNT]; @@ -284,12 +284,11 @@ export function validateNetworkMode( network: string | undefined, options?: ValidateNetworkModeOptions, ): void { - const normalized = network?.trim().toLowerCase(); - if (!normalized) { - return; - } - - if (BLOCKED_NETWORK_MODES.has(normalized)) { + const blockedReason = getBlockedNetworkModeReason({ + network, + allowContainerNamespaceJoin: options?.allowContainerNamespaceJoin, + }); + if (blockedReason === "host") { throw new Error( `Sandbox security: network mode "${network}" is blocked. ` + 'Network "host" mode bypasses container network isolation. ' + @@ -297,7 +296,7 @@ export function validateNetworkMode( ); } - if (normalized.startsWith("container:") && options?.allowContainerNamespaceJoin !== true) { + if (blockedReason === "container_namespace_join") { throw new Error( `Sandbox security: network mode "${network}" is blocked by default. ` + 'Network "container:*" joins another container namespace and bypasses sandbox network isolation. ' + diff --git a/src/config/config.sandbox-docker.test.ts b/src/config/config.sandbox-docker.test.ts index 032a68e857b8..71b24af01ef9 100644 --- a/src/config/config.sandbox-docker.test.ts +++ b/src/config/config.sandbox-docker.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { resolveSandboxBrowserConfig } from "../agents/sandbox/config.js"; +import { + resolveSandboxBrowserConfig, + resolveSandboxDockerConfig, +} from "../agents/sandbox/config.js"; import { validateConfigObject } from "./config.js"; describe("sandbox docker config", () => { @@ -84,6 +87,22 @@ describe("sandbox docker config", () => { expect(res.ok).toBe(true); }); + it("uses agent override precedence for dangerouslyAllowContainerNamespaceJoin", () => { + const inherited = resolveSandboxDockerConfig({ + scope: "agent", + globalDocker: { dangerouslyAllowContainerNamespaceJoin: true }, + agentDocker: {}, + }); + expect(inherited.dangerouslyAllowContainerNamespaceJoin).toBe(true); + + const overridden = resolveSandboxDockerConfig({ + scope: "agent", + globalDocker: { dangerouslyAllowContainerNamespaceJoin: true }, + agentDocker: { dangerouslyAllowContainerNamespaceJoin: false }, + }); + expect(overridden.dangerouslyAllowContainerNamespaceJoin).toBe(false); + }); + it("rejects seccomp unconfined via Zod schema validation", () => { const res = validateConfigObject({ agents: { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index a0d464274fd7..bac2c2dcae16 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -299,6 +299,10 @@ export const FIELD_HELP: Record = { "agents.defaults.sandbox.browser.network": "Docker network for sandbox browser containers (default: openclaw-sandbox-browser). Avoid bridge if you need stricter isolation.", "agents.list[].sandbox.browser.network": "Per-agent override for sandbox browser Docker network.", + "agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin": + "DANGEROUS break-glass override that allows sandbox Docker network mode container:. This joins another container namespace and weakens sandbox isolation.", + "agents.list[].sandbox.docker.dangerouslyAllowContainerNamespaceJoin": + "Per-agent DANGEROUS override for container namespace joins in sandbox Docker network mode.", "agents.defaults.sandbox.browser.cdpSourceRange": "Optional CIDR allowlist for container-edge CDP ingress (for example 172.21.0.1/32).", "agents.list[].sandbox.browser.cdpSourceRange": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 397376f6e111..f1706d1af7da 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -405,6 +405,8 @@ export const FIELD_LABELS: Record = { "agents.defaults.heartbeat.suppressToolErrorWarnings": "Heartbeat Suppress Tool Error Warnings", "agents.defaults.sandbox.browser.network": "Sandbox Browser Network", "agents.defaults.sandbox.browser.cdpSourceRange": "Sandbox Browser CDP Source Port Range", + "agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin": + "Sandbox Docker Allow Container Namespace Join", commands: "Commands", "commands.native": "Native Commands", "commands.nativeSkills": "Native Skill Commands", @@ -713,6 +715,8 @@ export const FIELD_LABELS: Record = { "Agent Heartbeat Suppress Tool Error Warnings", "agents.list[].sandbox.browser.network": "Agent Sandbox Browser Network", "agents.list[].sandbox.browser.cdpSourceRange": "Agent Sandbox Browser CDP Source Port Range", + "agents.list[].sandbox.docker.dangerouslyAllowContainerNamespaceJoin": + "Agent Sandbox Docker Allow Container Namespace Join", "discovery.mdns.mode": "mDNS Discovery Mode", plugins: "Plugins", "plugins.enabled": "Enable Plugins", diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index ca559ce5e941..c477cc1743b6 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { getBlockedNetworkModeReason } from "../agents/sandbox/network-mode.js"; import { parseDurationMs } from "../cli/parse-duration.js"; import { AgentModelSchema } from "./zod-schema.agent-model.js"; import { @@ -154,8 +155,11 @@ export const SandboxDockerSchema = z } } } - const network = data.network?.trim().toLowerCase(); - if (network === "host") { + const blockedNetworkReason = getBlockedNetworkModeReason({ + network: data.network, + allowContainerNamespaceJoin: data.dangerouslyAllowContainerNamespaceJoin === true, + }); + if (blockedNetworkReason === "host") { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["network"], @@ -163,7 +167,7 @@ export const SandboxDockerSchema = z 'Sandbox security: network mode "host" is blocked. Use "bridge" or "none" instead.', }); } - if (network?.startsWith("container:") && data.dangerouslyAllowContainerNamespaceJoin !== true) { + if (blockedNetworkReason === "container_namespace_join") { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["network"], @@ -476,11 +480,11 @@ export const AgentSandboxSchema = z }) .strict() .superRefine((data, ctx) => { - const browserNetwork = data.browser?.network?.trim().toLowerCase(); - if ( - browserNetwork?.startsWith("container:") && - data.docker?.dangerouslyAllowContainerNamespaceJoin !== true - ) { + const blockedBrowserNetworkReason = getBlockedNetworkModeReason({ + network: data.browser?.network, + allowContainerNamespaceJoin: data.docker?.dangerouslyAllowContainerNamespaceJoin === true, + }); + if (blockedBrowserNetworkReason === "container_namespace_join") { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["browser", "network"], diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index 893d1afb8a05..daa60aed73f0 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -3,6 +3,7 @@ import { resolveSandboxConfigForAgent, resolveSandboxToolPolicyForAgent, } from "../agents/sandbox.js"; +import { isDangerousNetworkMode, normalizeNetworkMode } from "../agents/sandbox/network-mode.js"; /** * Synchronous security audit collector functions. * @@ -830,8 +831,8 @@ export function collectSandboxDangerousConfigFindings(cfg: OpenClawConfig): Secu } const network = typeof docker.network === "string" ? docker.network : undefined; - const normalizedNetwork = network?.trim().toLowerCase(); - if (normalizedNetwork === "host" || normalizedNetwork?.startsWith("container:")) { + const normalizedNetwork = normalizeNetworkMode(network); + if (isDangerousNetworkMode(network)) { const modeLabel = normalizedNetwork === "host" ? '"host"' : `"${network}"`; const detail = normalizedNetwork === "host" From e7a5f9f4d8bb23e782c9985cc5001de72098d166 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:27:12 +0000 Subject: [PATCH 257/408] fix(channels,sandbox): land hard breakage cluster from reviewed PR bases Lands reviewed fixes based on #25839 (@pewallin), #25841 (@joshjhall), and #25737/@25713 (@DennisGoldfinger/@peteragility), with additional hardening + regression tests for queue cleanup and shell script safety. Fixes #25836 Fixes #25840 Fixes #25824 Fixes #25868 Co-authored-by: Peter Wallin Co-authored-by: Joshua Hall Co-authored-by: Dennis Goldfinger Co-authored-by: peteragility --- CHANGELOG.md | 3 + .../matrix/src/matrix/monitor/events.test.ts | 96 +++++++++ .../matrix/src/matrix/monitor/events.ts | 28 ++- .../matrix/src/matrix/monitor/handler.ts | 15 +- .../matrix/src/matrix/send-queue.test.ts | 89 +++++++++ extensions/matrix/src/matrix/send-queue.ts | 33 ++++ extensions/matrix/src/matrix/send.ts | 187 +++++++++--------- src/agents/sandbox/fs-bridge.test.ts | 14 +- src/agents/sandbox/fs-bridge.ts | 2 +- .../monitor/message-handler.process.test.ts | 23 ++- .../monitor/message-handler.process.ts | 5 +- 11 files changed, 380 insertions(+), 115 deletions(-) create mode 100644 extensions/matrix/src/matrix/monitor/events.test.ts create mode 100644 extensions/matrix/src/matrix/send-queue.test.ts create mode 100644 extensions/matrix/src/matrix/send-queue.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 62beb85b6948..914f5db6c97a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin. +- Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall. +- Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility. - Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. - Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch. - Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts new file mode 100644 index 000000000000..dbd2245046d4 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -0,0 +1,96 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MatrixAuth } from "../client.js"; +import { registerMatrixMonitorEvents } from "./events.js"; +import type { MatrixRawEvent } from "./types.js"; + +const sendReadReceiptMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +vi.mock("../send.js", () => ({ + sendReadReceiptMatrix: (...args: unknown[]) => sendReadReceiptMatrixMock(...args), +})); + +describe("registerMatrixMonitorEvents", () => { + beforeEach(() => { + sendReadReceiptMatrixMock.mockClear(); + }); + + function createHarness() { + const handlers = new Map void>(); + const client = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + handlers.set(event, handler); + }), + getUserId: vi.fn().mockResolvedValue("@bot:example.org"), + crypto: undefined, + } as unknown as MatrixClient; + + const onRoomMessage = vi.fn(); + const logVerboseMessage = vi.fn(); + const logger = { + warn: vi.fn(), + } as unknown as RuntimeLogger; + + registerMatrixMonitorEvents({ + client, + auth: { encryption: false } as MatrixAuth, + logVerboseMessage, + warnedEncryptedRooms: new Set(), + warnedCryptoMissingRooms: new Set(), + logger, + formatNativeDependencyHint: (() => + "") as PluginRuntime["system"]["formatNativeDependencyHint"], + onRoomMessage, + }); + + const roomMessageHandler = handlers.get("room.message"); + if (!roomMessageHandler) { + throw new Error("missing room.message handler"); + } + + return { client, onRoomMessage, roomMessageHandler }; + } + + it("sends read receipt immediately for non-self messages", async () => { + const { client, onRoomMessage, roomMessageHandler } = createHarness(); + const event = { + event_id: "$e1", + sender: "@alice:example.org", + } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", event); + + expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); + await vi.waitFor(() => { + expect(sendReadReceiptMatrixMock).toHaveBeenCalledWith("!room:example.org", "$e1", client); + }); + }); + + it("does not send read receipts for self messages", async () => { + const { onRoomMessage, roomMessageHandler } = createHarness(); + const event = { + event_id: "$e2", + sender: "@bot:example.org", + } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", event); + await vi.waitFor(() => { + expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); + }); + expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); + }); + + it("skips receipt when message lacks sender or event id", async () => { + const { onRoomMessage, roomMessageHandler } = createHarness(); + const event = { + sender: "@alice:example.org", + } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", event); + await vi.waitFor(() => { + expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); + }); + expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index 60bbe574add9..ab548ef18c22 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -1,6 +1,7 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk"; import type { MatrixAuth } from "../client.js"; +import { sendReadReceiptMatrix } from "../send.js"; import type { MatrixRawEvent } from "./types.js"; import { EventType } from "./types.js"; @@ -25,7 +26,32 @@ export function registerMatrixMonitorEvents(params: { onRoomMessage, } = params; - client.on("room.message", onRoomMessage); + let selfUserId: string | undefined; + client.on("room.message", (roomId: string, event: MatrixRawEvent) => { + const eventId = event?.event_id; + const senderId = event?.sender; + if (eventId && senderId) { + void (async () => { + if (!selfUserId) { + try { + selfUserId = await client.getUserId(); + } catch { + return; + } + } + if (senderId === selfUserId) { + return; + } + await sendReadReceiptMatrix(roomId, eventId, client).catch((err) => { + logVerboseMessage( + `matrix: early read receipt failed room=${roomId} id=${eventId}: ${String(err)}`, + ); + }); + })(); + } + + onRoomMessage(roomId, event); + }); client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => { const eventId = event?.event_id ?? "unknown"; diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index d884879001ef..c1df46fec2ad 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -18,12 +18,7 @@ import { parsePollStartContent, type PollStartContent, } from "../poll-types.js"; -import { - reactMatrixMessage, - sendMessageMatrix, - sendReadReceiptMatrix, - sendTypingMatrix, -} from "../send.js"; +import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js"; import { normalizeMatrixAllowList, resolveMatrixAllowListMatch, @@ -602,14 +597,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } - if (messageId) { - sendReadReceiptMatrix(roomId, messageId, client).catch((err) => { - logVerboseMessage( - `matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`, - ); - }); - } - let didSendReply = false; const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, diff --git a/extensions/matrix/src/matrix/send-queue.test.ts b/extensions/matrix/src/matrix/send-queue.test.ts new file mode 100644 index 000000000000..34e6e30166c0 --- /dev/null +++ b/extensions/matrix/src/matrix/send-queue.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { enqueueSend } from "./send-queue.js"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("enqueueSend", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("serializes sends per room", async () => { + const gate = deferred(); + const events: string[] = []; + + const first = enqueueSend("!room:example.org", async () => { + events.push("start1"); + await gate.promise; + events.push("end1"); + return "one"; + }); + const second = enqueueSend("!room:example.org", async () => { + events.push("start2"); + events.push("end2"); + return "two"; + }); + + await vi.advanceTimersByTimeAsync(150); + expect(events).toEqual(["start1"]); + + await vi.advanceTimersByTimeAsync(300); + expect(events).toEqual(["start1"]); + + gate.resolve(); + await first; + await vi.advanceTimersByTimeAsync(149); + expect(events).toEqual(["start1", "end1"]); + await vi.advanceTimersByTimeAsync(1); + await second; + expect(events).toEqual(["start1", "end1", "start2", "end2"]); + }); + + it("does not serialize across different rooms", async () => { + const events: string[] = []; + + const a = enqueueSend("!a:example.org", async () => { + events.push("a"); + return "a"; + }); + const b = enqueueSend("!b:example.org", async () => { + events.push("b"); + return "b"; + }); + + await vi.advanceTimersByTimeAsync(150); + await Promise.all([a, b]); + expect(events.sort()).toEqual(["a", "b"]); + }); + + it("continues queue after failures", async () => { + const first = enqueueSend("!room:example.org", async () => { + throw new Error("boom"); + }).then( + () => ({ ok: true as const }), + (error) => ({ ok: false as const, error }), + ); + + await vi.advanceTimersByTimeAsync(150); + const firstResult = await first; + expect(firstResult.ok).toBe(false); + expect(firstResult.error).toBeInstanceOf(Error); + expect((firstResult.error as Error).message).toBe("boom"); + + const second = enqueueSend("!room:example.org", async () => "ok"); + await vi.advanceTimersByTimeAsync(150); + await expect(second).resolves.toBe("ok"); + }); +}); diff --git a/extensions/matrix/src/matrix/send-queue.ts b/extensions/matrix/src/matrix/send-queue.ts new file mode 100644 index 000000000000..0d5e43b40e21 --- /dev/null +++ b/extensions/matrix/src/matrix/send-queue.ts @@ -0,0 +1,33 @@ +const SEND_GAP_MS = 150; + +// Serialize sends per room to preserve Matrix delivery order. +const roomQueues = new Map>(); + +export async function enqueueSend(roomId: string, fn: () => Promise): Promise { + const previous = roomQueues.get(roomId) ?? Promise.resolve(); + + const next = previous + .catch(() => {}) + .then(async () => { + await delay(SEND_GAP_MS); + return await fn(); + }); + + const queueMarker = next.then( + () => {}, + () => {}, + ); + roomQueues.set(roomId, queueMarker); + + queueMarker.finally(() => { + if (roomQueues.get(roomId) === queueMarker) { + roomQueues.delete(roomId); + } + }); + + return await next; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index b531b55dcdaf..dd72ec2883b3 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -2,6 +2,7 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { PollInput } from "openclaw/plugin-sdk"; import { getMatrixRuntime } from "../runtime.js"; import { buildPollStartContent, M_POLL_START } from "./poll-types.js"; +import { enqueueSend } from "./send-queue.js"; import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js"; import { buildReplyRelation, @@ -49,103 +50,105 @@ export async function sendMessageMatrix( }); try { const roomId = await resolveMatrixRoomId(client, to); - const cfg = getCore().config.loadConfig(); - const tableMode = getCore().channel.text.resolveMarkdownTableMode({ - cfg, - channel: "matrix", - accountId: opts.accountId, - }); - const convertedMessage = getCore().channel.text.convertMarkdownTables( - trimmedMessage, - tableMode, - ); - const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); - const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); - const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId); - const chunks = getCore().channel.text.chunkMarkdownTextWithMode( - convertedMessage, - chunkLimit, - chunkMode, - ); - const threadId = normalizeThreadId(opts.threadId); - const relation = threadId - ? buildThreadRelation(threadId, opts.replyToId) - : buildReplyRelation(opts.replyToId); - const sendContent = async (content: MatrixOutboundContent) => { - // @vector-im/matrix-bot-sdk uses sendMessage differently - const eventId = await client.sendMessage(roomId, content); - return eventId; - }; - - let lastMessageId = ""; - if (opts.mediaUrl) { - const maxBytes = resolveMediaMaxBytes(opts.accountId); - const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); - const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { - contentType: media.contentType, - filename: media.fileName, - }); - const durationMs = await resolveMediaDurationMs({ - buffer: media.buffer, - contentType: media.contentType, - fileName: media.fileName, - kind: media.kind, + return await enqueueSend(roomId, async () => { + const cfg = getCore().config.loadConfig(); + const tableMode = getCore().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "matrix", + accountId: opts.accountId, }); - const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName); - const { useVoice } = resolveMatrixVoiceDecision({ - wantsVoice: opts.audioAsVoice === true, - contentType: media.contentType, - fileName: media.fileName, - }); - const msgtype = useVoice ? MsgType.Audio : baseMsgType; - const isImage = msgtype === MsgType.Image; - const imageInfo = isImage - ? await prepareImageInfo({ buffer: media.buffer, client }) - : undefined; - const [firstChunk, ...rest] = chunks; - const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); - const content = buildMediaContent({ - msgtype, - body, - url: uploaded.url, - file: uploaded.file, - filename: media.fileName, - mimetype: media.contentType, - size: media.buffer.byteLength, - durationMs, - relation, - isVoice: useVoice, - imageInfo, - }); - const eventId = await sendContent(content); - lastMessageId = eventId ?? lastMessageId; - const textChunks = useVoice ? chunks : rest; - const followupRelation = threadId ? relation : undefined; - for (const chunk of textChunks) { - const text = chunk.trim(); - if (!text) { - continue; - } - const followup = buildTextContent(text, followupRelation); - const followupEventId = await sendContent(followup); - lastMessageId = followupEventId ?? lastMessageId; - } - } else { - for (const chunk of chunks.length ? chunks : [""]) { - const text = chunk.trim(); - if (!text) { - continue; - } - const content = buildTextContent(text, relation); + const convertedMessage = getCore().channel.text.convertMarkdownTables( + trimmedMessage, + tableMode, + ); + const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); + const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); + const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId); + const chunks = getCore().channel.text.chunkMarkdownTextWithMode( + convertedMessage, + chunkLimit, + chunkMode, + ); + const threadId = normalizeThreadId(opts.threadId); + const relation = threadId + ? buildThreadRelation(threadId, opts.replyToId) + : buildReplyRelation(opts.replyToId); + const sendContent = async (content: MatrixOutboundContent) => { + // @vector-im/matrix-bot-sdk uses sendMessage differently + const eventId = await client.sendMessage(roomId, content); + return eventId; + }; + + let lastMessageId = ""; + if (opts.mediaUrl) { + const maxBytes = resolveMediaMaxBytes(opts.accountId); + const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); + const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { + contentType: media.contentType, + filename: media.fileName, + }); + const durationMs = await resolveMediaDurationMs({ + buffer: media.buffer, + contentType: media.contentType, + fileName: media.fileName, + kind: media.kind, + }); + const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName); + const { useVoice } = resolveMatrixVoiceDecision({ + wantsVoice: opts.audioAsVoice === true, + contentType: media.contentType, + fileName: media.fileName, + }); + const msgtype = useVoice ? MsgType.Audio : baseMsgType; + const isImage = msgtype === MsgType.Image; + const imageInfo = isImage + ? await prepareImageInfo({ buffer: media.buffer, client }) + : undefined; + const [firstChunk, ...rest] = chunks; + const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); + const content = buildMediaContent({ + msgtype, + body, + url: uploaded.url, + file: uploaded.file, + filename: media.fileName, + mimetype: media.contentType, + size: media.buffer.byteLength, + durationMs, + relation, + isVoice: useVoice, + imageInfo, + }); const eventId = await sendContent(content); lastMessageId = eventId ?? lastMessageId; + const textChunks = useVoice ? chunks : rest; + const followupRelation = threadId ? relation : undefined; + for (const chunk of textChunks) { + const text = chunk.trim(); + if (!text) { + continue; + } + const followup = buildTextContent(text, followupRelation); + const followupEventId = await sendContent(followup); + lastMessageId = followupEventId ?? lastMessageId; + } + } else { + for (const chunk of chunks.length ? chunks : [""]) { + const text = chunk.trim(); + if (!text) { + continue; + } + const content = buildTextContent(text, relation); + const eventId = await sendContent(content); + lastMessageId = eventId ?? lastMessageId; + } } - } - return { - messageId: lastMessageId || "unknown", - roomId, - }; + return { + messageId: lastMessageId || "unknown", + roomId, + }; + }); } finally { if (stopOnDone) { client.stop(); diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts index f1d72be03b6c..982d3cbf6a55 100644 --- a/src/agents/sandbox/fs-bridge.test.ts +++ b/src/agents/sandbox/fs-bridge.test.ts @@ -77,10 +77,22 @@ describe("sandbox fs bridge shell compatibility", () => { const executables = mockedExecDockerRaw.mock.calls.map(([args]) => args[3] ?? ""); expect(executables.every((shell) => shell === "sh")).toBe(true); - expect(scripts.every((script) => script.includes("set -eu;"))).toBe(true); + expect(scripts.every((script) => /set -eu[;\n]/.test(script))).toBe(true); expect(scripts.some((script) => script.includes("pipefail"))).toBe(false); }); + it("resolveCanonicalContainerPath script is valid POSIX sh (no do; token)", async () => { + const bridge = createSandboxFsBridge({ sandbox: createSandbox() }); + + await bridge.readFile({ filePath: "a.txt" }); + + const scripts = mockedExecDockerRaw.mock.calls.map(([args]) => args[5] ?? ""); + const canonicalScript = scripts.find((script) => script.includes("allow_final")); + expect(canonicalScript).toBeDefined(); + // "; " joining can create "do; cmd", which is invalid in POSIX sh. + expect(canonicalScript).not.toMatch(/\bdo;/); + }); + it("resolves bind-mounted absolute container paths for reads", async () => { const sandbox = createSandbox({ docker: { diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts index fdcaf0cc46ce..dee44e1b2376 100644 --- a/src/agents/sandbox/fs-bridge.ts +++ b/src/agents/sandbox/fs-bridge.ts @@ -305,7 +305,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { "done", 'canonical=$(readlink -f -- "$cursor")', 'printf "%s%s\\n" "$canonical" "$suffix"', - ].join("; "); + ].join("\n"); const result = await this.runCommand(script, { args: [params.containerPath, params.allowFinalSymlink ? "1" : "0"], }); diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 482f61cfc3fd..79af5ffa477d 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -31,7 +31,10 @@ const deliverDiscordReply = deliveryMocks.deliverDiscordReply; const createDiscordDraftStream = deliveryMocks.createDiscordDraftStream; type DispatchInboundParams = { dispatcher: { - sendBlockReply: (payload: { text?: string }) => boolean | Promise; + sendBlockReply: (payload: { + text?: string; + isReasoning?: boolean; + }) => boolean | Promise; sendFinalReply: (payload: { text?: string }) => boolean | Promise; }; replyOptions?: { @@ -427,9 +430,9 @@ describe("processDiscordMessage draft streaming", () => { expect(deliverDiscordReply).toHaveBeenCalledTimes(1); }); - it("suppresses block-kind payload delivery to Discord", async () => { + it("suppresses reasoning payload delivery to Discord", async () => { dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { - await params?.dispatcher.sendBlockReply({ text: "thinking..." }); + await params?.dispatcher.sendBlockReply({ text: "thinking...", isReasoning: true }); return { queuedFinal: false, counts: { final: 0, tool: 0, block: 1 } }; }); @@ -441,6 +444,20 @@ describe("processDiscordMessage draft streaming", () => { expect(deliverDiscordReply).not.toHaveBeenCalled(); }); + it("delivers non-reasoning block payloads to Discord", async () => { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.dispatcher.sendBlockReply({ text: "hello from block stream" }); + return { queuedFinal: false, counts: { final: 0, tool: 0, block: 1 } }; + }); + + const ctx = await createBaseContext({ discordConfig: { streamMode: "off" } }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + expect(deliverDiscordReply).toHaveBeenCalledTimes(1); + }); + it("streams block previews using draft chunking", async () => { const draftStream = createMockDraftStream(); createDiscordDraftStream.mockReturnValueOnce(draftStream); diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 60966cff3ccc..4dd357d656f7 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -564,9 +564,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) humanDelay: resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload, info) => { const isFinal = info.kind === "final"; - if (info.kind === "block") { - // Block payloads carry reasoning/thinking content that should not be - // delivered to external channels. Skip them regardless of streamMode. + if (payload.isReasoning) { + // Reasoning/thinking payloads should not be delivered to Discord. return; } if (draftStream && isFinal) { From 9fccf60733131b671ce896bb133fb451916a07d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:28:24 +0000 Subject: [PATCH 258/408] refactor(synology-chat): centralize DM auth and fail fast startup --- docs/channels/synology-chat.md | 2 +- .../src/channel.integration.test.ts | 129 ++++++++++++++++++ extensions/synology-chat/src/channel.test.ts | 29 ++++ extensions/synology-chat/src/channel.ts | 6 + extensions/synology-chat/src/security.test.ts | 41 +++++- extensions/synology-chat/src/security.ts | 28 ++++ .../synology-chat/src/webhook-handler.ts | 26 ++-- 7 files changed, 245 insertions(+), 16 deletions(-) create mode 100644 extensions/synology-chat/src/channel.integration.test.ts diff --git a/docs/channels/synology-chat.md b/docs/channels/synology-chat.md index 37c5151f1c9c..89e96b318a3b 100644 --- a/docs/channels/synology-chat.md +++ b/docs/channels/synology-chat.md @@ -72,7 +72,7 @@ Config values override env vars. - `dmPolicy: "allowlist"` is the recommended default. - `allowedUserIds` accepts a list (or comma-separated string) of Synology user IDs. -- In `allowlist` mode, an empty `allowedUserIds` list blocks all senders (use `dmPolicy: "open"` for allow-all). +- In `allowlist` mode, an empty `allowedUserIds` list is treated as misconfiguration and the webhook route will not start (use `dmPolicy: "open"` for allow-all). - `dmPolicy: "open"` allows any sender. - `dmPolicy: "disabled"` blocks DMs. - Pairing approvals work with: diff --git a/extensions/synology-chat/src/channel.integration.test.ts b/extensions/synology-chat/src/channel.integration.test.ts new file mode 100644 index 000000000000..6005cbd923b2 --- /dev/null +++ b/extensions/synology-chat/src/channel.integration.test.ts @@ -0,0 +1,129 @@ +import { EventEmitter } from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type RegisteredRoute = { + path: string; + accountId: string; + handler: (req: IncomingMessage, res: ServerResponse) => Promise; +}; + +const registerPluginHttpRouteMock = vi.fn<(params: RegisteredRoute) => () => void>(() => vi.fn()); +const dispatchReplyWithBufferedBlockDispatcher = vi.fn().mockResolvedValue({ counts: {} }); + +vi.mock("openclaw/plugin-sdk", () => ({ + DEFAULT_ACCOUNT_ID: "default", + setAccountEnabledInConfigSection: vi.fn((_opts: any) => ({})), + registerPluginHttpRoute: registerPluginHttpRouteMock, + buildChannelConfigSchema: vi.fn((schema: any) => ({ schema })), +})); + +vi.mock("./runtime.js", () => ({ + getSynologyRuntime: vi.fn(() => ({ + config: { loadConfig: vi.fn().mockResolvedValue({}) }, + channel: { + reply: { + dispatchReplyWithBufferedBlockDispatcher, + }, + }, + })), +})); + +vi.mock("./client.js", () => ({ + sendMessage: vi.fn().mockResolvedValue(true), + sendFileUrl: vi.fn().mockResolvedValue(true), +})); + +const { createSynologyChatPlugin } = await import("./channel.js"); + +function makeReq(method: string, body: string): IncomingMessage { + const req = new EventEmitter() as IncomingMessage; + req.method = method; + req.socket = { remoteAddress: "127.0.0.1" } as any; + process.nextTick(() => { + req.emit("data", Buffer.from(body)); + req.emit("end"); + }); + return req; +} + +function makeRes(): ServerResponse & { _status: number; _body: string } { + const res = { + _status: 0, + _body: "", + writeHead(statusCode: number, _headers: Record) { + res._status = statusCode; + }, + end(body?: string) { + res._body = body ?? ""; + }, + } as any; + return res; +} + +function makeFormBody(fields: Record): string { + return Object.entries(fields) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join("&"); +} + +describe("Synology channel wiring integration", () => { + beforeEach(() => { + registerPluginHttpRouteMock.mockClear(); + dispatchReplyWithBufferedBlockDispatcher.mockClear(); + }); + + it("registers real webhook handler with resolved account config and enforces allowlist", async () => { + const plugin = createSynologyChatPlugin(); + const ctx = { + cfg: { + channels: { + "synology-chat": { + enabled: true, + accounts: { + alerts: { + enabled: true, + token: "valid-token", + incomingUrl: "https://nas.example.com/incoming", + webhookPath: "/webhook/synology-alerts", + dmPolicy: "allowlist", + allowedUserIds: ["456"], + }, + }, + }, + }, + }, + accountId: "alerts", + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + + const started = await plugin.gateway.startAccount(ctx); + expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(1); + + const firstCall = registerPluginHttpRouteMock.mock.calls[0]; + expect(firstCall).toBeTruthy(); + if (!firstCall) throw new Error("Expected registerPluginHttpRoute to be called"); + const registered = firstCall[0]; + expect(registered.path).toBe("/webhook/synology-alerts"); + expect(registered.accountId).toBe("alerts"); + expect(typeof registered.handler).toBe("function"); + + const req = makeReq( + "POST", + makeFormBody({ + token: "valid-token", + user_id: "123", + username: "unauthorized-user", + text: "Hello", + }), + ); + const res = makeRes(); + await registered.handler(req, res); + + expect(res._status).toBe(403); + expect(res._body).toContain("not authorized"); + expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + + started.stop(); + }); +}); diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 080cf4531c97..bc6c00a47126 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -357,6 +357,33 @@ describe("createSynologyChatPlugin", () => { expect(typeof result.stop).toBe("function"); }); + it("startAccount refuses allowlist accounts with empty allowedUserIds", async () => { + const registerMock = vi.mocked(registerPluginHttpRoute); + registerMock.mockClear(); + + const plugin = createSynologyChatPlugin(); + const ctx = { + cfg: { + channels: { + "synology-chat": { + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + dmPolicy: "allowlist", + allowedUserIds: [], + }, + }, + }, + accountId: "default", + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + + const result = await plugin.gateway.startAccount(ctx); + expect(typeof result.stop).toBe("function"); + expect(ctx.log.warn).toHaveBeenCalledWith(expect.stringContaining("empty allowedUserIds")); + expect(registerMock).not.toHaveBeenCalled(); + }); + it("deregisters stale route before re-registering same account/path", async () => { const unregisterFirst = vi.fn(); const unregisterSecond = vi.fn(); @@ -372,6 +399,8 @@ describe("createSynologyChatPlugin", () => { token: "t", incomingUrl: "https://nas/incoming", webhookPath: "/webhook/synology", + dmPolicy: "allowlist", + allowedUserIds: ["123"], }, }, }, diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 287028abc24d..431dfd2cbd2d 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -226,6 +226,12 @@ export function createSynologyChatPlugin() { ); return { stop: () => {} }; } + if (account.dmPolicy === "allowlist" && account.allowedUserIds.length === 0) { + log?.warn?.( + `Synology Chat account ${accountId} has dmPolicy=allowlist but empty allowedUserIds; refusing to start route`, + ); + return { stop: () => {} }; + } log?.info?.( `Starting Synology Chat channel (account: ${accountId}, path: ${account.webhookPath})`, diff --git a/extensions/synology-chat/src/security.test.ts b/extensions/synology-chat/src/security.test.ts index 7e639da94862..c6f697810af0 100644 --- a/extensions/synology-chat/src/security.test.ts +++ b/extensions/synology-chat/src/security.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from "vitest"; -import { validateToken, checkUserAllowed, sanitizeInput, RateLimiter } from "./security.js"; +import { + validateToken, + checkUserAllowed, + authorizeUserForDm, + sanitizeInput, + RateLimiter, +} from "./security.js"; describe("validateToken", () => { it("returns true for matching tokens", () => { @@ -37,6 +43,39 @@ describe("checkUserAllowed", () => { }); }); +describe("authorizeUserForDm", () => { + it("allows any user when dmPolicy is open", () => { + expect(authorizeUserForDm("user1", "open", [])).toEqual({ allowed: true }); + }); + + it("rejects all users when dmPolicy is disabled", () => { + expect(authorizeUserForDm("user1", "disabled", ["user1"])).toEqual({ + allowed: false, + reason: "disabled", + }); + }); + + it("rejects when dmPolicy is allowlist and list is empty", () => { + expect(authorizeUserForDm("user1", "allowlist", [])).toEqual({ + allowed: false, + reason: "allowlist-empty", + }); + }); + + it("rejects users not in allowlist", () => { + expect(authorizeUserForDm("user9", "allowlist", ["user1"])).toEqual({ + allowed: false, + reason: "not-allowlisted", + }); + }); + + it("allows users in allowlist", () => { + expect(authorizeUserForDm("user1", "allowlist", ["user1", "user2"])).toEqual({ + allowed: true, + }); + }); +}); + describe("sanitizeInput", () => { it("returns normal text unchanged", () => { expect(sanitizeInput("hello world")).toBe("hello world"); diff --git a/extensions/synology-chat/src/security.ts b/extensions/synology-chat/src/security.ts index 12336b876b92..2e1b14312732 100644 --- a/extensions/synology-chat/src/security.ts +++ b/extensions/synology-chat/src/security.ts @@ -4,6 +4,10 @@ import * as crypto from "node:crypto"; +export type DmAuthorizationResult = + | { allowed: true } + | { allowed: false; reason: "disabled" | "allowlist-empty" | "not-allowlisted" }; + /** * Validate webhook token using constant-time comparison. * Prevents timing attacks that could leak token bytes. @@ -28,6 +32,30 @@ export function checkUserAllowed(userId: string, allowedUserIds: string[]): bool return allowedUserIds.includes(userId); } +/** + * Resolve DM authorization for a sender across all DM policy modes. + * Keeps policy semantics in one place so webhook/startup behavior stays consistent. + */ +export function authorizeUserForDm( + userId: string, + dmPolicy: "open" | "allowlist" | "disabled", + allowedUserIds: string[], +): DmAuthorizationResult { + if (dmPolicy === "disabled") { + return { allowed: false, reason: "disabled" }; + } + if (dmPolicy === "open") { + return { allowed: true }; + } + if (allowedUserIds.length === 0) { + return { allowed: false, reason: "allowlist-empty" }; + } + if (!checkUserAllowed(userId, allowedUserIds)) { + return { allowed: false, reason: "not-allowlisted" }; + } + return { allowed: true }; +} + /** * Sanitize user input to prevent prompt injection attacks. * Filters known dangerous patterns and truncates long messages. diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index 0ed5dd844c5b..b077e61fc7c4 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -6,7 +6,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import * as querystring from "node:querystring"; import { sendMessage } from "./client.js"; -import { validateToken, checkUserAllowed, sanitizeInput, RateLimiter } from "./security.js"; +import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js"; import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js"; // One rate limiter per account, created lazily @@ -137,25 +137,23 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) { return; } - // User allowlist check - if (account.dmPolicy === "disabled") { - respond(res, 403, { error: "DMs are disabled" }); - return; - } - - if (account.dmPolicy === "allowlist") { - if (account.allowedUserIds.length === 0) { + // DM policy authorization + const auth = authorizeUserForDm(payload.user_id, account.dmPolicy, account.allowedUserIds); + if (!auth.allowed) { + if (auth.reason === "disabled") { + respond(res, 403, { error: "DMs are disabled" }); + return; + } + if (auth.reason === "allowlist-empty") { log?.warn("Synology Chat allowlist is empty while dmPolicy=allowlist; rejecting message"); respond(res, 403, { error: "Allowlist is empty. Configure allowedUserIds or use dmPolicy=open.", }); return; } - if (!checkUserAllowed(payload.user_id, account.allowedUserIds)) { - log?.warn(`Unauthorized user: ${payload.user_id}`); - respond(res, 403, { error: "User not authorized" }); - return; - } + log?.warn(`Unauthorized user: ${payload.user_id}`); + respond(res, 403, { error: "User not authorized" }); + return; } // Rate limit From 9b531021006715bfacae70999d93a572cfcfd5f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:24:51 +0000 Subject: [PATCH 259/408] test: add routing/session isolation edge-case regressions --- .../reply/agent-runner-payloads.test.ts | 23 ++++++++ src/auto-reply/reply/followup-runner.test.ts | 58 +++++++++++++++++++ src/auto-reply/reply/reply-flow.test.ts | 3 + src/infra/outbound/targets.test.ts | 35 +++++++++++ 4 files changed, 119 insertions(+) diff --git a/src/auto-reply/reply/agent-runner-payloads.test.ts b/src/auto-reply/reply/agent-runner-payloads.test.ts index 40d0ae72ad3e..9b62db984e8e 100644 --- a/src/auto-reply/reply/agent-runner-payloads.test.ts +++ b/src/auto-reply/reply/agent-runner-payloads.test.ts @@ -85,4 +85,27 @@ describe("buildReplyPayloads media filter integration", () => { expect(replyPayloads).toHaveLength(0); }); + + it("does not suppress same-target replies when accountId differs", () => { + const { replyPayloads } = buildReplyPayloads({ + ...baseParams, + payloads: [{ text: "hello world!" }], + messageProvider: "heartbeat", + originatingChannel: "telegram", + originatingTo: "268300329", + accountId: "personal", + messagingToolSentTexts: ["different message"], + messagingToolSentTargets: [ + { + tool: "telegram", + provider: "telegram", + to: "268300329", + accountId: "work", + }, + ], + }); + + expect(replyPayloads).toHaveLength(1); + expect(replyPayloads[0]?.text).toBe("hello world!"); + }); }); diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index a77bb0be44e1..189267b8d943 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -238,6 +238,36 @@ describe("createFollowupRunner messaging tool dedupe", () => { expect(onBlockReply).not.toHaveBeenCalled(); }); + it("does not suppress replies for same target when account differs", async () => { + const onBlockReply = vi.fn(async () => {}); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "hello world!" }], + messagingToolSentTexts: ["different message"], + messagingToolSentTargets: [ + { tool: "telegram", provider: "telegram", to: "268300329", accountId: "work" }, + ], + meta: {}, + }); + + const runner = createMessagingDedupeRunner(onBlockReply); + + await runner({ + ...baseQueuedRun("heartbeat"), + originatingChannel: "telegram", + originatingTo: "268300329", + originatingAccountId: "personal", + } as FollowupRun); + + expect(routeReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + to: "268300329", + accountId: "personal", + }), + ); + expect(onBlockReply).not.toHaveBeenCalled(); + }); + it("drops media URL from payload when messaging tool already sent it", async () => { const onBlockReply = vi.fn(async () => {}); runEmbeddedPiAgentMock.mockResolvedValueOnce({ @@ -335,6 +365,34 @@ describe("createFollowupRunner messaging tool dedupe", () => { expect(routeReplyMock).toHaveBeenCalled(); expect(onBlockReply).not.toHaveBeenCalled(); }); + + it("routes followups with originating account/thread metadata", async () => { + const onBlockReply = vi.fn(async () => {}); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "hello world!" }], + meta: {}, + }); + + const runner = createMessagingDedupeRunner(onBlockReply); + + await runner({ + ...baseQueuedRun("webchat"), + originatingChannel: "discord", + originatingTo: "channel:C1", + originatingAccountId: "work", + originatingThreadId: "1739142736.000100", + } as FollowupRun); + + expect(routeReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "discord", + to: "channel:C1", + accountId: "work", + threadId: "1739142736.000100", + }), + ); + expect(onBlockReply).not.toHaveBeenCalled(); + }); }); describe("createFollowupRunner agentDir forwarding", () => { diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index 3b91cf52d40e..03ff953be7c3 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -1068,6 +1068,7 @@ describe("followup queue collect routing", () => { prompt: "first", originatingChannel: "discord", originatingTo: "channel:C1", + originatingAccountId: "work", originatingThreadId: "1739142736.000100", }), settings, @@ -1078,6 +1079,7 @@ describe("followup queue collect routing", () => { prompt: "second", originatingChannel: "discord", originatingTo: "channel:C1", + originatingAccountId: "work", originatingThreadId: "1739142736.000100", }), settings, @@ -1088,6 +1090,7 @@ describe("followup queue collect routing", () => { expect(calls[0]?.originatingChannel).toBe("discord"); expect(calls[0]?.originatingTo).toBe("channel:C1"); + expect(calls[0]?.originatingAccountId).toBe("work"); expect(calls[0]?.originatingThreadId).toBe("1739142736.000100"); expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); }); diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 9d7ec7dde5ec..5cc004a4b3a6 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -321,4 +321,39 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.to).toBe("user:U123"); expect(resolved.threadId).toBeUndefined(); }); + + it("keeps explicit threadId in heartbeat mode", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-heartbeat-explicit-thread", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "-100123", + lastThreadId: 999, + }, + requestedChannel: "last", + mode: "heartbeat", + explicitThreadId: 42, + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("-100123"); + expect(resolved.threadId).toBe(42); + expect(resolved.threadIdExplicit).toBe(true); + }); + + it("parses explicit heartbeat topic targets into threadId", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + heartbeat: { + target: "telegram", + to: "63448508:topic:1008013", + }, + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("63448508"); + expect(resolved.threadId).toBe(1008013); + }); }); From 54648a9cf15d56399040695aeaf6c96712183d38 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:28:26 +0000 Subject: [PATCH 260/408] refactor: centralize followup origin routing helpers --- src/auto-reply/reply/agent-runner-payloads.ts | 18 ++++- src/auto-reply/reply/agent-runner-utils.ts | 14 ++-- src/auto-reply/reply/agent-runner.ts | 15 +++-- src/auto-reply/reply/followup-runner.test.ts | 41 +++++------- src/auto-reply/reply/followup-runner.ts | 26 +++++-- src/auto-reply/reply/get-reply-run.ts | 9 +-- src/auto-reply/reply/origin-routing.test.ts | 43 ++++++++++++ src/auto-reply/reply/origin-routing.ts | 29 ++++++++ src/auto-reply/reply/queue/drain.ts | 67 ++++++++++--------- 9 files changed, 183 insertions(+), 79 deletions(-) create mode 100644 src/auto-reply/reply/origin-routing.test.ts create mode 100644 src/auto-reply/reply/origin-routing.ts diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index 6aaa93aa633e..38737171c35f 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -6,6 +6,11 @@ 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 { + resolveOriginAccountId, + resolveOriginMessageProvider, + resolveOriginMessageTo, +} from "./origin-routing.js"; import { normalizeReplyPayloadDirectives } from "./reply-delivery.js"; import { applyReplyThreading, @@ -87,10 +92,17 @@ export function buildReplyPayloads(params: { const messagingToolSentTexts = params.messagingToolSentTexts ?? []; const messagingToolSentTargets = params.messagingToolSentTargets ?? []; const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({ - messageProvider: params.originatingChannel ?? params.messageProvider, + messageProvider: resolveOriginMessageProvider({ + originatingChannel: params.originatingChannel, + provider: params.messageProvider, + }), messagingToolSentTargets, - originatingTo: params.originatingTo, - accountId: params.accountId, + originatingTo: resolveOriginMessageTo({ + originatingTo: params.originatingTo, + }), + accountId: resolveOriginAccountId({ + originatingAccountId: params.accountId, + }), }); // Only dedupe against messaging tool sends for the same origin target. // Cross-target sends (for example posting to another channel) must not diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index c3d09877a4dd..c3902010c5ba 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -9,6 +9,7 @@ import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js"; import type { TemplateContext } from "../templating.js"; import type { ReplyPayload } from "../types.js"; +import { resolveOriginMessageProvider, resolveOriginMessageTo } from "./origin-routing.js"; import type { FollowupRun } from "./queue.js"; const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i; @@ -196,12 +197,15 @@ export function buildEmbeddedContextFromTemplate(params: { sessionId: params.run.sessionId, sessionKey: params.run.sessionKey, agentId: params.run.agentId, - messageProvider: - params.sessionCtx.OriginatingChannel?.trim().toLowerCase() || - params.sessionCtx.Provider?.trim().toLowerCase() || - undefined, + messageProvider: resolveOriginMessageProvider({ + originatingChannel: params.sessionCtx.OriginatingChannel, + provider: params.sessionCtx.Provider, + }), agentAccountId: params.sessionCtx.AccountId, - messageTo: params.sessionCtx.OriginatingTo ?? params.sessionCtx.To, + messageTo: resolveOriginMessageTo({ + originatingTo: params.sessionCtx.OriginatingTo, + to: params.sessionCtx.To, + }), messageThreadId: params.sessionCtx.MessageThreadId ?? undefined, // Provider threading context for tool auto-injection ...buildThreadingToolContext({ diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index e3f47246e7ca..33e4c0f7a904 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -43,6 +43,7 @@ import { appendUsageLine, formatResponseUsageLine } from "./agent-runner-utils.j import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-reply-pipeline.js"; import { resolveBlockStreamingCoalescing } from "./block-streaming.js"; import { createFollowupRunner } from "./followup-runner.js"; +import { resolveOriginMessageProvider, resolveOriginMessageTo } from "./origin-routing.js"; import { auditPostCompactionReads, extractReadPaths, @@ -179,11 +180,10 @@ export async function runReplyAgent(params: { const pendingToolTasks = new Set>(); const blockReplyTimeoutMs = opts?.blockReplyTimeoutMs ?? BLOCK_REPLY_SEND_TIMEOUT_MS; - const replyToChannel = - sessionCtx.OriginatingChannel ?? - ((sessionCtx.Surface ?? sessionCtx.Provider)?.toLowerCase() as - | OriginatingChannelType - | undefined); + const replyToChannel = resolveOriginMessageProvider({ + originatingChannel: sessionCtx.OriginatingChannel, + provider: sessionCtx.Surface ?? sessionCtx.Provider, + }) as OriginatingChannelType | undefined; const replyToMode = resolveReplyToMode( followupRun.run.config, replyToChannel, @@ -515,7 +515,10 @@ export async function runReplyAgent(params: { messagingToolSentMediaUrls: runResult.messagingToolSentMediaUrls, messagingToolSentTargets: runResult.messagingToolSentTargets, originatingChannel: sessionCtx.OriginatingChannel, - originatingTo: sessionCtx.OriginatingTo ?? sessionCtx.To, + originatingTo: resolveOriginMessageTo({ + originatingTo: sessionCtx.OriginatingTo, + to: sessionCtx.To, + }), accountId: sessionCtx.AccountId, }); const { replyPayloads } = payloadResult; diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 189267b8d943..a9d4249e597c 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -63,6 +63,20 @@ const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun => }, }) as FollowupRun; +function createQueuedRun( + overrides: Partial & { run?: Partial } = {}, +): FollowupRun { + const base = baseQueuedRun(); + return { + ...base, + ...overrides, + run: { + ...base.run, + ...overrides.run, + }, + }; +} + function mockCompactionRun(params: { willRetry: boolean; result: { @@ -114,32 +128,11 @@ describe("createFollowupRunner compaction", () => { defaultModel: "anthropic/claude-opus-4-5", }); - const queued = { - prompt: "hello", - summaryLine: "hello", - enqueuedAt: Date.now(), + const queued = createQueuedRun({ run: { - sessionId: "session", - sessionKey: "main", - messageProvider: "whatsapp", - sessionFile: "/tmp/session.jsonl", - workspaceDir: "/tmp", - config: {}, - skillsSnapshot: {}, - provider: "anthropic", - model: "claude", - thinkLevel: "low", verboseLevel: "on", - elevatedLevel: "off", - bashElevated: { - enabled: false, - allowed: false, - defaultLevel: "off", - }, - timeoutMs: 1_000, - blockReplyBreak: "message_end", }, - } as FollowupRun; + }); await runner(queued); @@ -411,7 +404,7 @@ describe("createFollowupRunner agentDir forwarding", () => { defaultModel: "anthropic/claude-opus-4-5", }); const agentDir = path.join("/tmp", "agent-dir"); - const queued = baseQueuedRun(); + const queued = createQueuedRun(); await runner({ ...queued, run: { diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 5c0ec491f564..a73c5f0b0160 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -14,6 +14,11 @@ import type { OriginatingChannelType } from "../templating.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { resolveRunAuthProfile } from "./agent-runner-utils.js"; +import { + resolveOriginAccountId, + resolveOriginMessageProvider, + resolveOriginMessageTo, +} from "./origin-routing.js"; import type { FollowupRun } from "./queue.js"; import { applyReplyThreading, @@ -231,9 +236,10 @@ export function createFollowupRunner(params: { } return [{ ...payload, text: stripped.text }]; }); - const replyToChannel = - queued.originatingChannel ?? - (queued.run.messageProvider?.toLowerCase() as OriginatingChannelType | undefined); + const replyToChannel = resolveOriginMessageProvider({ + originatingChannel: queued.originatingChannel, + provider: queued.run.messageProvider, + }) as OriginatingChannelType | undefined; const replyToMode = resolveReplyToMode( queued.run.config, replyToChannel, @@ -256,10 +262,18 @@ export function createFollowupRunner(params: { sentMediaUrls: runResult.messagingToolSentMediaUrls ?? [], }); const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({ - messageProvider: queued.originatingChannel ?? queued.run.messageProvider, + messageProvider: resolveOriginMessageProvider({ + originatingChannel: queued.originatingChannel, + provider: queued.run.messageProvider, + }), messagingToolSentTargets: runResult.messagingToolSentTargets, - originatingTo: queued.originatingTo, - accountId: queued.originatingAccountId ?? queued.run.agentAccountId, + originatingTo: resolveOriginMessageTo({ + originatingTo: queued.originatingTo, + }), + accountId: resolveOriginAccountId({ + originatingAccountId: queued.originatingAccountId, + accountId: queued.run.agentAccountId, + }), }); const finalPayloads = suppressMessagingToolReplies ? [] : mediaFilteredPayloads; diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index edcf15b31c70..b4c4e36281e7 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -40,6 +40,7 @@ import type { InlineDirectives } from "./directive-handling.js"; import { buildGroupChatContext, buildGroupIntro } from "./groups.js"; import { buildInboundMetaSystemPrompt, buildInboundUserContextPrefix } from "./inbound-meta.js"; import type { createModelSelectionState } from "./model-selection.js"; +import { resolveOriginMessageProvider } from "./origin-routing.js"; import { resolveQueueSettings } from "./queue.js"; import { routeReply } from "./route-reply.js"; import { BARE_SESSION_RESET_PROMPT } from "./session-reset-prompt.js"; @@ -460,10 +461,10 @@ export async function runPreparedReply( agentDir, sessionId: sessionIdFinal, sessionKey, - messageProvider: - sessionCtx.OriginatingChannel?.trim().toLowerCase() || - sessionCtx.Provider?.trim().toLowerCase() || - undefined, + messageProvider: resolveOriginMessageProvider({ + originatingChannel: sessionCtx.OriginatingChannel, + provider: sessionCtx.Provider, + }), agentAccountId: sessionCtx.AccountId, groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined, groupChannel: sessionCtx.GroupChannel?.trim() ?? sessionCtx.GroupSubject?.trim(), diff --git a/src/auto-reply/reply/origin-routing.test.ts b/src/auto-reply/reply/origin-routing.test.ts new file mode 100644 index 000000000000..c4d18762e37e --- /dev/null +++ b/src/auto-reply/reply/origin-routing.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + resolveOriginAccountId, + resolveOriginMessageProvider, + resolveOriginMessageTo, +} from "./origin-routing.js"; + +describe("origin-routing helpers", () => { + it("prefers originating channel over provider for message provider", () => { + const provider = resolveOriginMessageProvider({ + originatingChannel: "Telegram", + provider: "heartbeat", + }); + + expect(provider).toBe("telegram"); + }); + + it("falls back to provider when originating channel is missing", () => { + const provider = resolveOriginMessageProvider({ + provider: " Slack ", + }); + + expect(provider).toBe("slack"); + }); + + it("prefers originating destination over fallback destination", () => { + const to = resolveOriginMessageTo({ + originatingTo: "channel:C1", + to: "channel:C2", + }); + + expect(to).toBe("channel:C1"); + }); + + it("prefers originating account over fallback account", () => { + const accountId = resolveOriginAccountId({ + originatingAccountId: "work", + accountId: "personal", + }); + + expect(accountId).toBe("work"); + }); +}); diff --git a/src/auto-reply/reply/origin-routing.ts b/src/auto-reply/reply/origin-routing.ts new file mode 100644 index 000000000000..ce8936ab6596 --- /dev/null +++ b/src/auto-reply/reply/origin-routing.ts @@ -0,0 +1,29 @@ +import type { OriginatingChannelType } from "../templating.js"; + +function normalizeProviderValue(value?: string): string | undefined { + const normalized = value?.trim().toLowerCase(); + return normalized || undefined; +} + +export function resolveOriginMessageProvider(params: { + originatingChannel?: OriginatingChannelType; + provider?: string; +}): string | undefined { + return ( + normalizeProviderValue(params.originatingChannel) ?? normalizeProviderValue(params.provider) + ); +} + +export function resolveOriginMessageTo(params: { + originatingTo?: string; + to?: string; +}): string | undefined { + return params.originatingTo ?? params.to; +} + +export function resolveOriginAccountId(params: { + originatingAccountId?: string; + accountId?: string; +}): string | undefined { + return params.originatingAccountId ?? params.accountId; +} diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index b37c2b01f158..a048a4e89255 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -13,6 +13,39 @@ import { isRoutableChannel } from "../route-reply.js"; import { FOLLOWUP_QUEUES } from "./state.js"; import type { FollowupRun } from "./types.js"; +type OriginRoutingMetadata = Pick< + FollowupRun, + "originatingChannel" | "originatingTo" | "originatingAccountId" | "originatingThreadId" +>; + +function resolveOriginRoutingMetadata(items: FollowupRun[]): OriginRoutingMetadata { + return { + originatingChannel: items.find((item) => item.originatingChannel)?.originatingChannel, + originatingTo: items.find((item) => item.originatingTo)?.originatingTo, + originatingAccountId: items.find((item) => item.originatingAccountId)?.originatingAccountId, + // Support both number (Telegram topic) and string (Slack thread_ts) thread IDs. + originatingThreadId: items.find( + (item) => item.originatingThreadId != null && item.originatingThreadId !== "", + )?.originatingThreadId, + }; +} + +function resolveCrossChannelKey(item: FollowupRun): { cross?: true; key?: string } { + const { originatingChannel: channel, originatingTo: to, originatingAccountId: accountId } = item; + const threadId = item.originatingThreadId; + if (!channel && !to && !accountId && (threadId == null || threadId === "")) { + return {}; + } + if (!isRoutableChannel(channel) || !to) { + return { cross: true }; + } + // Support both number (Telegram topic IDs) and string (Slack thread_ts) thread IDs. + const threadKey = threadId != null && threadId !== "" ? String(threadId) : ""; + return { + key: [channel, to, accountId || "", threadKey].join("|"), + }; +} + export function scheduleFollowupDrain( key: string, runFollowup: (run: FollowupRun) => Promise, @@ -33,23 +66,7 @@ export function scheduleFollowupDrain( // Debug: `pnpm test src/auto-reply/reply/reply-flow.test.ts` // Check if messages span multiple channels. // If so, process individually to preserve per-message routing. - const isCrossChannel = hasCrossChannelItems(queue.items, (item) => { - const channel = item.originatingChannel; - const to = item.originatingTo; - const accountId = item.originatingAccountId; - const threadId = item.originatingThreadId; - if (!channel && !to && !accountId && (threadId == null || threadId === "")) { - return {}; - } - if (!isRoutableChannel(channel) || !to) { - return { cross: true }; - } - // Support both number (Telegram topic IDs) and string (Slack thread_ts) thread IDs. - const threadKey = threadId != null && threadId !== "" ? String(threadId) : ""; - return { - key: [channel, to, accountId || "", threadKey].join("|"), - }; - }); + const isCrossChannel = hasCrossChannelItems(queue.items, resolveCrossChannelKey); const collectDrainResult = await drainCollectQueueStep({ collectState, @@ -71,16 +88,7 @@ export function scheduleFollowupDrain( break; } - // Preserve originating channel from items when collecting same-channel. - const originatingChannel = items.find((i) => i.originatingChannel)?.originatingChannel; - const originatingTo = items.find((i) => i.originatingTo)?.originatingTo; - const originatingAccountId = items.find( - (i) => i.originatingAccountId, - )?.originatingAccountId; - // Support both number (Telegram topic) and string (Slack thread_ts) thread IDs. - const originatingThreadId = items.find( - (i) => i.originatingThreadId != null && i.originatingThreadId !== "", - )?.originatingThreadId; + const routing = resolveOriginRoutingMetadata(items); const prompt = buildCollectPrompt({ title: "[Queued messages while agent was busy]", @@ -92,10 +100,7 @@ export function scheduleFollowupDrain( prompt, run, enqueuedAt: Date.now(), - originatingChannel, - originatingTo, - originatingAccountId, - originatingThreadId, + ...routing, }); queue.items.splice(0, items.length); if (summary) { From 5c2a483375d6856ea1cc014c842d19af21c1d791 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:28:46 +0000 Subject: [PATCH 261/408] refactor(outbound): centralize attachment media policy --- src/infra/outbound/message-action-params.ts | 83 ++++++++++++++----- .../outbound/message-action-runner.test.ts | 14 ++++ src/infra/outbound/message-action-runner.ts | 13 +-- 3 files changed, 84 insertions(+), 26 deletions(-) diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index e9672841e1cc..b24146cb97cd 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -169,6 +169,59 @@ function normalizeBase64Payload(params: { base64?: string; contentType?: string }; } +export type AttachmentMediaPolicy = + | { + mode: "sandbox"; + sandboxRoot: string; + } + | { + mode: "host"; + localRoots?: readonly string[]; + }; + +export function resolveAttachmentMediaPolicy(params: { + sandboxRoot?: string; + mediaLocalRoots?: readonly string[]; +}): AttachmentMediaPolicy { + const sandboxRoot = params.sandboxRoot?.trim(); + if (sandboxRoot) { + return { + mode: "sandbox", + sandboxRoot, + }; + } + return { + mode: "host", + localRoots: params.mediaLocalRoots, + }; +} + +function buildAttachmentMediaLoadOptions(params: { + policy: AttachmentMediaPolicy; + maxBytes?: number; +}): + | { + maxBytes?: number; + sandboxValidated: true; + readFile: (filePath: string) => Promise; + } + | { + maxBytes?: number; + localRoots?: readonly string[]; + } { + if (params.policy.mode === "sandbox") { + return { + maxBytes: params.maxBytes, + sandboxValidated: true, + readFile: (filePath: string) => fs.readFile(filePath), + }; + } + return { + maxBytes: params.maxBytes, + localRoots: params.policy.localRoots, + }; +} + async function hydrateAttachmentPayload(params: { cfg: OpenClawConfig; channel: ChannelId; @@ -178,8 +231,7 @@ async function hydrateAttachmentPayload(params: { contentTypeParam?: string | null; mediaHint?: string | null; fileHint?: string | null; - sandboxRoot?: string; - mediaLocalRoots?: readonly string[]; + mediaPolicy: AttachmentMediaPolicy; }) { const contentTypeParam = params.contentTypeParam ?? undefined; const rawBuffer = readStringParam(params.args, "buffer", { trim: false }); @@ -203,17 +255,10 @@ async function hydrateAttachmentPayload(params: { channel: params.channel, accountId: params.accountId, }); - const sandboxRoot = params.sandboxRoot?.trim(); - const media = sandboxRoot - ? await loadWebMedia(mediaSource, { - maxBytes, - sandboxValidated: true, - readFile: (filePath: string) => fs.readFile(filePath), - }) - : await loadWebMedia(mediaSource, { - maxBytes, - localRoots: params.mediaLocalRoots, - }); + const media = await loadWebMedia( + mediaSource, + buildAttachmentMediaLoadOptions({ policy: params.mediaPolicy, maxBytes }), + ); params.args.buffer = media.buffer.toString("base64"); if (!contentTypeParam && media.contentType) { params.args.contentType = media.contentType; @@ -287,8 +332,7 @@ async function hydrateAttachmentActionPayload(params: { dryRun?: boolean; /** If caption is missing, copy message -> caption. */ allowMessageCaptionFallback?: boolean; - sandboxRoot?: string; - mediaLocalRoots?: readonly string[]; + mediaPolicy: AttachmentMediaPolicy; }): Promise { const mediaHint = readStringParam(params.args, "media", { trim: false }); const fileHint = @@ -314,8 +358,7 @@ async function hydrateAttachmentActionPayload(params: { contentTypeParam, mediaHint, fileHint, - sandboxRoot: params.sandboxRoot, - mediaLocalRoots: params.mediaLocalRoots, + mediaPolicy: params.mediaPolicy, }); } @@ -326,8 +369,7 @@ export async function hydrateSetGroupIconParams(params: { args: Record; action: ChannelMessageActionName; dryRun?: boolean; - sandboxRoot?: string; - mediaLocalRoots?: readonly string[]; + mediaPolicy: AttachmentMediaPolicy; }): Promise { if (params.action !== "setGroupIcon") { return; @@ -342,8 +384,7 @@ export async function hydrateSendAttachmentParams(params: { args: Record; action: ChannelMessageActionName; dryRun?: boolean; - sandboxRoot?: string; - mediaLocalRoots?: readonly string[]; + mediaPolicy: AttachmentMediaPolicy; }): Promise { if (params.action !== "sendAttachment") { return; diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index 054350a4043f..127a38380310 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -512,6 +512,15 @@ describe("runMessageAction sendAttachment hydration", () => { expect((result.payload as { buffer?: string }).buffer).toBe( Buffer.from("hello").toString("base64"), ); + const call = vi.mocked(loadWebMedia).mock.calls[0]; + expect(call?.[1]).toEqual( + expect.objectContaining({ + localRoots: expect.any(Array), + }), + ); + expect((call?.[1] as { sandboxValidated?: boolean } | undefined)?.sandboxValidated).not.toBe( + true, + ); }); it("rewrites sandboxed media paths for sendAttachment", async () => { @@ -530,6 +539,11 @@ describe("runMessageAction sendAttachment hydration", () => { const call = vi.mocked(loadWebMedia).mock.calls[0]; expect(call?.[0]).toBe(path.join(sandboxDir, "data", "pic.png")); + expect(call?.[1]).toEqual( + expect.objectContaining({ + sandboxValidated: true, + }), + ); }); }); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 5031a2cdead7..68a75f0c0a36 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -36,6 +36,7 @@ import { parseCardParam, parseComponentsParam, readBooleanParam, + resolveAttachmentMediaPolicy, resolveSlackAutoThreadId, resolveTelegramAutoThreadId, } from "./message-action-params.js"; @@ -759,10 +760,14 @@ export async function runMessageAction( } const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun")); const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, resolvedAgentId); + const mediaPolicy = resolveAttachmentMediaPolicy({ + sandboxRoot: input.sandboxRoot, + mediaLocalRoots, + }); await normalizeSandboxMediaParams({ args: params, - sandboxRoot: input.sandboxRoot, + sandboxRoot: mediaPolicy.mode === "sandbox" ? mediaPolicy.sandboxRoot : undefined, }); await hydrateSendAttachmentParams({ @@ -772,8 +777,7 @@ export async function runMessageAction( args: params, action, dryRun, - sandboxRoot: input.sandboxRoot, - mediaLocalRoots, + mediaPolicy, }); await hydrateSetGroupIconParams({ @@ -783,8 +787,7 @@ export async function runMessageAction( args: params, action, dryRun, - sandboxRoot: input.sandboxRoot, - mediaLocalRoots, + mediaPolicy, }); const resolvedTarget = await resolveActionTarget({ From 4355e08262029c9fe7f450915f20ef3867aa219e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:29:12 +0000 Subject: [PATCH 262/408] refactor: harden safe-bin trusted dir diagnostics --- src/agents/bash-tools.exec.ts | 3 + .../doctor-config-flow.safe-bins.test.ts | 46 ++++++++++ src/commands/doctor-config-flow.ts | 77 ++++++++++++++++ .../exec-safe-bin-runtime-policy.test.ts | 34 ++++++- src/infra/exec-safe-bin-runtime-policy.ts | 35 ++++++-- src/infra/exec-safe-bin-trust.test.ts | 24 +++++ src/infra/exec-safe-bin-trust.ts | 36 ++++++++ src/node-host/invoke-system-run.ts | 11 +++ src/security/audit.test.ts | 44 ++++++++++ src/security/audit.ts | 88 ++++++++++++++++++- 10 files changed, 391 insertions(+), 7 deletions(-) diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 7fd16e36eaf6..a9d230c24b62 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -175,6 +175,9 @@ export function createExecTool( safeBinTrustedDirs: defaults?.safeBinTrustedDirs, safeBinProfiles: defaults?.safeBinProfiles, }, + onWarning: (message) => { + logInfo(message); + }, }); if (unprofiledSafeBins.length > 0) { logInfo( diff --git a/src/commands/doctor-config-flow.safe-bins.test.ts b/src/commands/doctor-config-flow.safe-bins.test.ts index 3d7a646a8dde..802cfeb8d969 100644 --- a/src/commands/doctor-config-flow.safe-bins.test.ts +++ b/src/commands/doctor-config-flow.safe-bins.test.ts @@ -1,4 +1,8 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { withEnvAsync } from "../test-utils/env.js"; import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js"; const { noteSpy } = vi.hoisted(() => ({ @@ -86,4 +90,46 @@ describe("doctor config flow safe bins", () => { "Doctor warnings", ); }); + + it("hints safeBinTrustedDirs when safeBins resolve outside default trusted dirs", async () => { + if (process.platform === "win32") { + return; + } + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-safe-bins-")); + const binPath = path.join(dir, "mydoctorbin"); + try { + await fs.writeFile(binPath, "#!/bin/sh\necho ok\n", "utf-8"); + await fs.chmod(binPath, 0o755); + await withEnvAsync( + { + PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + async () => { + await runDoctorConfigWithInput({ + config: { + tools: { + exec: { + safeBins: ["mydoctorbin"], + safeBinProfiles: { + mydoctorbin: {}, + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + }, + ); + expect(noteSpy).toHaveBeenCalledWith( + expect.stringContaining("outside trusted safe-bin dirs"), + "Doctor warnings", + ); + expect(noteSpy).toHaveBeenCalledWith( + expect.stringContaining("tools.exec.safeBinTrustedDirs"), + "Doctor warnings", + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); }); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index e86dec9e819d..f4a7e4132a8d 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -17,10 +17,16 @@ import { import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { parseToolsBySenderTypedKey } from "../config/types.tools.js"; +import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js"; import { listInterpreterLikeSafeBins, resolveMergedSafeBinProfileFixtures, } from "../infra/exec-safe-bin-runtime-policy.js"; +import { + getTrustedSafeBinDirs, + isTrustedSafeBinPath, + normalizeTrustedSafeBinDirs, +} from "../infra/exec-safe-bin-trust.js"; import { isDiscordMutableAllowEntry, isGoogleChatMutableAllowEntry, @@ -1001,6 +1007,13 @@ type ExecSafeBinScopeRef = { safeBins: string[]; exec: Record; mergedProfiles: Record; + trustedSafeBinDirs: ReadonlySet; +}; + +type ExecSafeBinTrustedDirHintHit = { + scopePath: string; + bin: string; + resolvedPath: string; }; function normalizeConfiguredSafeBins(entries: unknown): string[] { @@ -1016,9 +1029,19 @@ function normalizeConfiguredSafeBins(entries: unknown): string[] { ).toSorted(); } +function normalizeConfiguredTrustedSafeBinDirs(entries: unknown): string[] { + if (!Array.isArray(entries)) { + return []; + } + return normalizeTrustedSafeBinDirs( + entries.filter((entry): entry is string => typeof entry === "string"), + ); +} + function collectExecSafeBinScopes(cfg: OpenClawConfig): ExecSafeBinScopeRef[] { const scopes: ExecSafeBinScopeRef[] = []; const globalExec = asObjectRecord(cfg.tools?.exec); + const globalTrustedDirs = normalizeConfiguredTrustedSafeBinDirs(globalExec?.safeBinTrustedDirs); if (globalExec) { const safeBins = normalizeConfiguredSafeBins(globalExec.safeBins); if (safeBins.length > 0) { @@ -1030,6 +1053,9 @@ function collectExecSafeBinScopes(cfg: OpenClawConfig): ExecSafeBinScopeRef[] { resolveMergedSafeBinProfileFixtures({ global: globalExec, }) ?? {}, + trustedSafeBinDirs: getTrustedSafeBinDirs({ + extraDirs: globalTrustedDirs, + }), }); } } @@ -1055,6 +1081,12 @@ function collectExecSafeBinScopes(cfg: OpenClawConfig): ExecSafeBinScopeRef[] { global: globalExec, local: agentExec, }) ?? {}, + trustedSafeBinDirs: getTrustedSafeBinDirs({ + extraDirs: [ + ...globalTrustedDirs, + ...normalizeConfiguredTrustedSafeBinDirs(agentExec.safeBinTrustedDirs), + ], + }), }); } return scopes; @@ -1078,6 +1110,32 @@ function scanExecSafeBinCoverage(cfg: OpenClawConfig): ExecSafeBinCoverageHit[] return hits; } +function scanExecSafeBinTrustedDirHints(cfg: OpenClawConfig): ExecSafeBinTrustedDirHintHit[] { + const hits: ExecSafeBinTrustedDirHintHit[] = []; + for (const scope of collectExecSafeBinScopes(cfg)) { + for (const bin of scope.safeBins) { + const resolution = resolveCommandResolutionFromArgv([bin]); + if (!resolution?.resolvedPath) { + continue; + } + if ( + isTrustedSafeBinPath({ + resolvedPath: resolution.resolvedPath, + trustedDirs: scope.trustedSafeBinDirs, + }) + ) { + continue; + } + hits.push({ + scopePath: scope.scopePath, + bin, + resolvedPath: resolution.resolvedPath, + }); + } + } + return hits; +} + function maybeRepairExecSafeBinProfiles(cfg: OpenClawConfig): { config: OpenClawConfig; changes: string[]; @@ -1488,6 +1546,25 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { ); note(lines.join("\n"), "Doctor warnings"); } + + const safeBinTrustedDirHints = scanExecSafeBinTrustedDirHints(candidate); + if (safeBinTrustedDirHints.length > 0) { + const lines = safeBinTrustedDirHints + .slice(0, 5) + .map( + (hit) => + `- ${hit.scopePath}.safeBins entry '${hit.bin}' resolves to '${hit.resolvedPath}' outside trusted safe-bin dirs.`, + ); + if (safeBinTrustedDirHints.length > 5) { + lines.push( + `- ${safeBinTrustedDirHints.length - 5} more safeBins entries resolve outside trusted safe-bin dirs.`, + ); + } + lines.push( + "- If intentional, add the binary directory to tools.exec.safeBinTrustedDirs (global or agent scope).", + ); + note(lines.join("\n"), "Doctor warnings"); + } } const mutableAllowlistHits = scanMutableAllowlistEntries(candidate); diff --git a/src/infra/exec-safe-bin-runtime-policy.test.ts b/src/infra/exec-safe-bin-runtime-policy.test.ts index 94cc868c5b28..af5510be5f29 100644 --- a/src/infra/exec-safe-bin-runtime-policy.test.ts +++ b/src/infra/exec-safe-bin-runtime-policy.test.ts @@ -1,5 +1,7 @@ +import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { isInterpreterLikeSafeBin, listInterpreterLikeSafeBins, @@ -103,4 +105,34 @@ describe("exec safe-bin runtime policy", () => { expect(optedIn.trustedSafeBinDirs.has(path.resolve("/opt/homebrew/bin"))).toBe(true); expect(optedIn.trustedSafeBinDirs.has(path.resolve("/usr/local/bin"))).toBe(true); }); + + it("emits runtime warning when explicitly trusted dir is writable", async () => { + if (process.platform === "win32") { + return; + } + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-safe-bin-runtime-")); + try { + await fs.chmod(dir, 0o777); + const onWarning = vi.fn(); + const policy = resolveExecSafeBinRuntimePolicy({ + global: { + safeBinTrustedDirs: [dir], + }, + onWarning, + }); + + expect(policy.writableTrustedSafeBinDirs).toEqual([ + { + dir: path.resolve(dir), + groupWritable: true, + worldWritable: true, + }, + ]); + expect(onWarning).toHaveBeenCalledWith(expect.stringContaining(path.resolve(dir))); + expect(onWarning).toHaveBeenCalledWith(expect.stringContaining("world-writable")); + } finally { + await fs.chmod(dir, 0o755).catch(() => undefined); + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); }); diff --git a/src/infra/exec-safe-bin-runtime-policy.ts b/src/infra/exec-safe-bin-runtime-policy.ts index 9ed56bfe680e..955d130de11e 100644 --- a/src/infra/exec-safe-bin-runtime-policy.ts +++ b/src/infra/exec-safe-bin-runtime-policy.ts @@ -6,7 +6,12 @@ import { type SafeBinProfileFixture, type SafeBinProfileFixtures, } from "./exec-safe-bin-policy.js"; -import { getTrustedSafeBinDirs, normalizeTrustedSafeBinDirs } from "./exec-safe-bin-trust.js"; +import { + getTrustedSafeBinDirs, + listWritableExplicitTrustedSafeBinDirs, + normalizeTrustedSafeBinDirs, + type WritableTrustedSafeBinDir, +} from "./exec-safe-bin-trust.js"; export type ExecSafeBinConfigScope = { safeBins?: string[] | null; @@ -99,12 +104,14 @@ export function resolveMergedSafeBinProfileFixtures(params: { export function resolveExecSafeBinRuntimePolicy(params: { global?: ExecSafeBinConfigScope | null; local?: ExecSafeBinConfigScope | null; + onWarning?: (message: string) => void; }): { safeBins: Set; safeBinProfiles: Readonly>; trustedSafeBinDirs: ReadonlySet; unprofiledSafeBins: string[]; unprofiledInterpreterSafeBins: string[]; + writableTrustedSafeBinDirs: ReadonlyArray; } { const safeBins = resolveSafeBins(params.local?.safeBins ?? params.global?.safeBins); const safeBinProfiles = resolveSafeBinProfiles( @@ -116,17 +123,35 @@ export function resolveExecSafeBinRuntimePolicy(params: { const unprofiledSafeBins = Array.from(safeBins) .filter((entry) => !safeBinProfiles[entry]) .toSorted(); + const explicitTrustedSafeBinDirs = [ + ...normalizeTrustedSafeBinDirs(params.global?.safeBinTrustedDirs), + ...normalizeTrustedSafeBinDirs(params.local?.safeBinTrustedDirs), + ]; const trustedSafeBinDirs = getTrustedSafeBinDirs({ - extraDirs: [ - ...normalizeTrustedSafeBinDirs(params.global?.safeBinTrustedDirs), - ...normalizeTrustedSafeBinDirs(params.local?.safeBinTrustedDirs), - ], + extraDirs: explicitTrustedSafeBinDirs, }); + const writableTrustedSafeBinDirs = listWritableExplicitTrustedSafeBinDirs( + explicitTrustedSafeBinDirs, + ); + if (params.onWarning) { + for (const hit of writableTrustedSafeBinDirs) { + const scope = + hit.worldWritable || hit.groupWritable + ? hit.worldWritable + ? "world-writable" + : "group-writable" + : "writable"; + params.onWarning( + `exec: safeBinTrustedDirs includes ${scope} directory '${hit.dir}'; remove trust or tighten permissions (for example chmod 755).`, + ); + } + } return { safeBins, safeBinProfiles, trustedSafeBinDirs, unprofiledSafeBins, unprofiledInterpreterSafeBins: listInterpreterLikeSafeBins(unprofiledSafeBins), + writableTrustedSafeBinDirs, }; } diff --git a/src/infra/exec-safe-bin-trust.test.ts b/src/infra/exec-safe-bin-trust.test.ts index eccd6cce986f..c22d062b8939 100644 --- a/src/infra/exec-safe-bin-trust.test.ts +++ b/src/infra/exec-safe-bin-trust.test.ts @@ -1,3 +1,5 @@ +import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { withEnv } from "../test-utils/env.js"; @@ -5,6 +7,7 @@ import { buildTrustedSafeBinDirs, getTrustedSafeBinDirs, isTrustedSafeBinPath, + listWritableExplicitTrustedSafeBinDirs, } from "./exec-safe-bin-trust.js"; describe("exec safe bin trust", () => { @@ -69,4 +72,25 @@ describe("exec safe bin trust", () => { expect(refreshed.has(path.resolve(injected))).toBe(false); }); }); + + it("flags explicitly trusted dirs that are group/world writable", async () => { + if (process.platform === "win32") { + return; + } + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-safe-bin-trust-")); + try { + await fs.chmod(dir, 0o777); + const hits = listWritableExplicitTrustedSafeBinDirs([dir]); + expect(hits).toEqual([ + { + dir: path.resolve(dir), + groupWritable: true, + worldWritable: true, + }, + ]); + } finally { + await fs.chmod(dir, 0o755).catch(() => undefined); + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); }); diff --git a/src/infra/exec-safe-bin-trust.ts b/src/infra/exec-safe-bin-trust.ts index e939ac717115..418a6d49200e 100644 --- a/src/infra/exec-safe-bin-trust.ts +++ b/src/infra/exec-safe-bin-trust.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; // Keep defaults to OS-managed immutable bins only. @@ -19,6 +20,12 @@ type TrustedSafeBinCache = { dirs: Set; }; +export type WritableTrustedSafeBinDir = { + dir: string; + groupWritable: boolean; + worldWritable: boolean; +}; + let trustedSafeBinCache: TrustedSafeBinCache | null = null; function normalizeTrustedDir(value: string): string | null { @@ -88,3 +95,32 @@ export function isTrustedSafeBinPath(params: TrustedSafeBinPathParams): boolean const resolvedDir = path.dirname(path.resolve(params.resolvedPath)); return trustedDirs.has(resolvedDir); } + +export function listWritableExplicitTrustedSafeBinDirs( + entries?: readonly string[] | null, +): WritableTrustedSafeBinDir[] { + if (process.platform === "win32") { + return []; + } + const resolved = resolveTrustedSafeBinDirs(normalizeTrustedSafeBinDirs(entries)); + const hits: WritableTrustedSafeBinDir[] = []; + for (const dir of resolved) { + let stat: fs.Stats; + try { + stat = fs.statSync(dir); + } catch { + continue; + } + if (!stat.isDirectory()) { + continue; + } + const mode = stat.mode & 0o777; + const groupWritable = (mode & 0o020) !== 0; + const worldWritable = (mode & 0o002) !== 0; + if (!groupWritable && !worldWritable) { + continue; + } + hits.push({ dir, groupWritable, worldWritable }); + } + return hits; +} diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index da97464966a3..897d8ebd111b 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -55,6 +55,16 @@ type SystemRunAllowlistAnalysis = { segments: ExecCommandSegment[]; }; +const safeBinTrustedDirWarningCache = new Set(); + +function warnWritableTrustedDirOnce(message: string): void { + if (safeBinTrustedDirWarningCache.has(message)) { + return; + } + safeBinTrustedDirWarningCache.add(message); + console.warn(message); +} + function normalizeDeniedReason(reason: string | null | undefined): SystemRunDeniedReason { switch (reason) { case "security=deny": @@ -310,6 +320,7 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): const { safeBins, safeBinProfiles, trustedSafeBinDirs } = resolveExecSafeBinRuntimePolicy({ global: cfg.tools?.exec, local: agentExec, + onWarning: warnWritableTrustedDirOnce, }); const bins = autoAllowSkills ? await opts.skillBins.current() : []; let { analysisOk, allowlistMatches, allowlistSatisfied, segments } = evaluateSystemRunAllowlist({ diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 4354c32b77be..04c459070414 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -438,6 +438,50 @@ describe("security audit", () => { ); }); + it("warns for risky safeBinTrustedDirs entries", async () => { + const cfg: OpenClawConfig = { + tools: { + exec: { + safeBinTrustedDirs: ["/usr/local/bin", "/tmp/openclaw-safe-bins"], + }, + }, + agents: { + list: [ + { + id: "ops", + tools: { + exec: { + safeBinTrustedDirs: ["./relative-bin-dir"], + }, + }, + }, + ], + }, + }; + + const res = await audit(cfg); + const finding = res.findings.find( + (f) => f.checkId === "tools.exec.safe_bin_trusted_dirs_risky", + ); + expect(finding?.severity).toBe("warn"); + expect(finding?.detail).toContain("/usr/local/bin"); + expect(finding?.detail).toContain("/tmp/openclaw-safe-bins"); + expect(finding?.detail).toContain("agents.list.ops.tools.exec"); + }); + + it("does not warn for non-risky absolute safeBinTrustedDirs entries", async () => { + const cfg: OpenClawConfig = { + tools: { + exec: { + safeBinTrustedDirs: ["/usr/libexec"], + }, + }, + }; + + const res = await audit(cfg); + expectNoFinding(res, "tools.exec.safe_bin_trusted_dirs_risky"); + }); + it("evaluates loopback control UI and logging exposure findings", async () => { const cases: Array<{ name: string; diff --git a/src/security/audit.ts b/src/security/audit.ts index c1714ca49698..e6254b5cc805 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1,4 +1,5 @@ import { isIP } from "node:net"; +import path from "node:path"; import { resolveSandboxConfigForAgent } from "../agents/sandbox.js"; import { execDockerRaw } from "../agents/sandbox/docker.js"; import { resolveBrowserConfig, resolveProfile } from "../browser/config.js"; @@ -15,6 +16,7 @@ import { listInterpreterLikeSafeBins, resolveMergedSafeBinProfileFixtures, } from "../infra/exec-safe-bin-runtime-policy.js"; +import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js"; import { collectChannelSecurityFindings } from "./audit-channel.js"; import { collectAttackSurfaceSummaryFindings, @@ -748,8 +750,77 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] ), ).toSorted(); }; - const interpreterHits: string[] = []; + const normalizeConfiguredTrustedDirs = (entries: unknown): string[] => { + if (!Array.isArray(entries)) { + return []; + } + return normalizeTrustedSafeBinDirs( + entries.filter((entry): entry is string => typeof entry === "string"), + ); + }; + const classifyRiskySafeBinTrustedDir = (entry: string): string | null => { + const raw = entry.trim(); + if (!raw) { + return null; + } + if (!path.isAbsolute(raw)) { + return "relative path (trust boundary depends on process cwd)"; + } + const normalized = path.resolve(raw).replace(/\\/g, "/").toLowerCase(); + if ( + normalized === "/tmp" || + normalized.startsWith("/tmp/") || + normalized === "/var/tmp" || + normalized.startsWith("/var/tmp/") || + normalized === "/private/tmp" || + normalized.startsWith("/private/tmp/") + ) { + return "temporary directory is mutable and easy to poison"; + } + if ( + normalized === "/usr/local/bin" || + normalized === "/opt/homebrew/bin" || + normalized === "/opt/local/bin" || + normalized === "/home/linuxbrew/.linuxbrew/bin" + ) { + return "package-manager bin directory (often user-writable)"; + } + if ( + normalized.startsWith("/users/") || + normalized.startsWith("/home/") || + normalized.includes("/.local/bin") + ) { + return "home-scoped bin directory (typically user-writable)"; + } + if (/^[a-z]:\/users\//.test(normalized)) { + return "home-scoped bin directory (typically user-writable)"; + } + return null; + }; + const globalExec = cfg.tools?.exec; + const riskyTrustedDirHits: string[] = []; + const collectRiskyTrustedDirHits = (scopePath: string, entries: unknown): void => { + for (const entry of normalizeConfiguredTrustedDirs(entries)) { + const reason = classifyRiskySafeBinTrustedDir(entry); + if (!reason) { + continue; + } + riskyTrustedDirHits.push(`- ${scopePath}.safeBinTrustedDirs: ${entry} (${reason})`); + } + }; + collectRiskyTrustedDirHits("tools.exec", globalExec?.safeBinTrustedDirs); + for (const entry of agents) { + if (!entry || typeof entry !== "object" || typeof entry.id !== "string") { + continue; + } + collectRiskyTrustedDirHits( + `agents.list.${entry.id}.tools.exec`, + entry.tools?.exec?.safeBinTrustedDirs, + ); + } + + const interpreterHits: string[] = []; const globalSafeBins = normalizeConfiguredSafeBins(globalExec?.safeBins); if (globalSafeBins.length > 0) { const merged = resolveMergedSafeBinProfileFixtures({ global: globalExec }) ?? {}; @@ -795,6 +866,21 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] }); } + if (riskyTrustedDirHits.length > 0) { + findings.push({ + checkId: "tools.exec.safe_bin_trusted_dirs_risky", + severity: "warn", + title: "safeBinTrustedDirs includes risky mutable directories", + detail: + `Detected risky safeBinTrustedDirs entries:\n${riskyTrustedDirHits.slice(0, 10).join("\n")}` + + (riskyTrustedDirHits.length > 10 + ? `\n- +${riskyTrustedDirHits.length - 10} more entries.` + : ""), + remediation: + "Prefer root-owned immutable bins, keep default trust dirs (/bin, /usr/bin), and avoid trusting temporary/home/package-manager paths unless tightly controlled.", + }); + } + return findings; } From b4010a0b627025c809c0e5dbdbd4770f3bc59ef8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:30:05 +0000 Subject: [PATCH 263/408] fix(zalo): enforce group sender policy in groups --- CHANGELOG.md | 1 + docs/channels/groups.md | 6 +- docs/channels/zalo.md | 38 ++++-- extensions/zalo/src/channel.ts | 31 ++++- extensions/zalo/src/config-schema.ts | 2 + .../zalo/src/monitor.group-policy.test.ts | 106 ++++++++++++++++ extensions/zalo/src/monitor.ts | 113 ++++++++++++++++++ extensions/zalo/src/types.ts | 4 + 8 files changed, 284 insertions(+), 17 deletions(-) create mode 100644 extensions/zalo/src/monitor.group-policy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 914f5db6c97a..6cf8e3f9fb72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. - Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. This ships in the next npm release. Thanks @GCXWLP for reporting. +- Zalo/Group policy: enforce sender authorization for group messages with `groupPolicy` + `groupAllowFrom` (fallback to `allowFrom`), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so `dmPolicy: "allowlist"` with empty `allowedUserIds` rejects all senders instead of allowing unauthorized dispatch. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/docs/channels/groups.md b/docs/channels/groups.md index de848243c9c1..8b8af64b94cb 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -1,5 +1,5 @@ --- -summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/Microsoft Teams)" +summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/Microsoft Teams/Zalo)" read_when: - Changing group chat behavior or mention gating title: "Groups" @@ -7,7 +7,7 @@ title: "Groups" # Groups -OpenClaw treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Microsoft Teams. +OpenClaw treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Microsoft Teams, Zalo. ## Beginner intro (2 minutes) @@ -183,7 +183,7 @@ Control how group/room messages are handled per channel: Notes: - `groupPolicy` is separate from mention-gating (which requires @mentions). -- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams: use `groupAllowFrom` (fallback: explicit `allowFrom`). +- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams/Zalo: use `groupAllowFrom` (fallback: explicit `allowFrom`). - Discord: allowlist uses `channels.discord.guilds..channels`. - Slack: allowlist uses `channels.slack.channels`. - Matrix: allowlist uses `channels.matrix.groups` (room IDs, aliases, or names). Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported. diff --git a/docs/channels/zalo.md b/docs/channels/zalo.md index cda126f56491..8e5d8ab0382a 100644 --- a/docs/channels/zalo.md +++ b/docs/channels/zalo.md @@ -7,7 +7,7 @@ title: "Zalo" # Zalo (Bot API) -Status: experimental. Direct messages only; groups coming soon per Zalo docs. +Status: experimental. DMs are supported; group handling is available with explicit group policy controls. ## Plugin required @@ -51,7 +51,7 @@ It is a good fit for support or notifications where you want deterministic routi - A Zalo Bot API channel owned by the Gateway. - Deterministic routing: replies go back to Zalo; the model never chooses channels. - DMs share the agent's main session. -- Groups are not yet supported (Zalo docs state "coming soon"). +- Groups are supported with policy controls (`groupPolicy` + `groupAllowFrom`) and default to fail-closed allowlist behavior. ## Setup (fast path) @@ -107,6 +107,16 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and - Pairing is the default token exchange. Details: [Pairing](/channels/pairing) - `channels.zalo.allowFrom` accepts numeric user IDs (no username lookup available). +## Access control (Groups) + +- `channels.zalo.groupPolicy` controls group inbound handling: `open | allowlist | disabled`. +- Default behavior is fail-closed: `allowlist`. +- `channels.zalo.groupAllowFrom` restricts which sender IDs can trigger the bot in groups. +- If `groupAllowFrom` is unset, Zalo falls back to `allowFrom` for sender checks. +- `groupPolicy: "disabled"` blocks all group messages. +- `groupPolicy: "open"` allows any group member (mention-gated). +- Runtime note: if `channels.zalo` is missing entirely, runtime still falls back to `groupPolicy="allowlist"` for safety. + ## Long-polling vs webhook - Default: long-polling (no public URL required). @@ -130,16 +140,16 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and ## Capabilities -| Feature | Status | -| --------------- | ------------------------------ | -| Direct messages | ✅ Supported | -| Groups | ❌ Coming soon (per Zalo docs) | -| Media (images) | ✅ Supported | -| Reactions | ❌ Not supported | -| Threads | ❌ Not supported | -| Polls | ❌ Not supported | -| Native commands | ❌ Not supported | -| Streaming | ⚠️ Blocked (2000 char limit) | +| Feature | Status | +| --------------- | -------------------------------------------------------- | +| Direct messages | ✅ Supported | +| Groups | ⚠️ Supported with policy controls (allowlist by default) | +| Media (images) | ✅ Supported | +| Reactions | ❌ Not supported | +| Threads | ❌ Not supported | +| Polls | ❌ Not supported | +| Native commands | ❌ Not supported | +| Streaming | ⚠️ Blocked (2000 char limit) | ## Delivery targets (CLI/cron) @@ -172,6 +182,8 @@ Provider options: - `channels.zalo.tokenFile`: read token from file path. - `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs. +- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist). +- `channels.zalo.groupAllowFrom`: group sender allowlist (user IDs). Falls back to `allowFrom` when unset. - `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5). - `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required). - `channels.zalo.webhookSecret`: webhook secret (8-256 chars). @@ -186,6 +198,8 @@ Multi-account options: - `channels.zalo.accounts..enabled`: enable/disable account. - `channels.zalo.accounts..dmPolicy`: per-account DM policy. - `channels.zalo.accounts..allowFrom`: per-account allowlist. +- `channels.zalo.accounts..groupPolicy`: per-account group policy. +- `channels.zalo.accounts..groupAllowFrom`: per-account group sender allowlist. - `channels.zalo.accounts..webhookUrl`: per-account webhook URL. - `channels.zalo.accounts..webhookSecret`: per-account webhook secret. - `channels.zalo.accounts..webhookPath`: per-account webhook path. diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 9e263f0bff8d..34706e168828 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -16,6 +16,8 @@ import { migrateBaseNameToDefaultAccount, normalizeAccountId, PAIRING_APPROVED_MESSAGE, + resolveDefaultGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, resolveChannelAccountConfigBasePath, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk"; @@ -56,7 +58,7 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined { export const zaloDock: ChannelDock = { id: "zalo", capabilities: { - chatTypes: ["direct"], + chatTypes: ["direct", "group"], media: true, blockStreaming: true, }, @@ -82,7 +84,7 @@ export const zaloPlugin: ChannelPlugin = { meta, onboarding: zaloOnboardingAdapter, capabilities: { - chatTypes: ["direct"], + chatTypes: ["direct", "group"], media: true, reactions: false, threads: false, @@ -143,6 +145,31 @@ export const zaloPlugin: ChannelPlugin = { normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""), }; }, + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.zalo !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + if (groupPolicy !== "open") { + return []; + } + const explicitGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => + String(entry), + ); + const dmAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry)); + const effectiveAllowFrom = + explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom; + if (effectiveAllowFrom.length > 0) { + return [ + `- Zalo groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom to restrict senders.`, + ]; + } + return [ + `- Zalo groups: groupPolicy="open" with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom.`, + ]; + }, }, groups: { resolveRequireMention: () => true, diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index db4fba278143..a38a0a1cbfd9 100644 --- a/extensions/zalo/src/config-schema.ts +++ b/extensions/zalo/src/config-schema.ts @@ -14,6 +14,8 @@ const zaloAccountSchema = z.object({ webhookPath: z.string().optional(), dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), allowFrom: z.array(allowFromEntry).optional(), + groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(), + groupAllowFrom: z.array(allowFromEntry).optional(), mediaMaxMb: z.number().optional(), proxy: z.string().optional(), responsePrefix: z.string().optional(), diff --git a/extensions/zalo/src/monitor.group-policy.test.ts b/extensions/zalo/src/monitor.group-policy.test.ts new file mode 100644 index 000000000000..2ce0b1be2a23 --- /dev/null +++ b/extensions/zalo/src/monitor.group-policy.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { __testing } from "./monitor.js"; + +describe("zalo group policy access", () => { + it("defaults missing provider config to allowlist", () => { + const resolved = __testing.resolveZaloRuntimeGroupPolicy({ + providerConfigPresent: false, + groupPolicy: undefined, + defaultGroupPolicy: "open", + }); + expect(resolved).toEqual({ + groupPolicy: "allowlist", + providerMissingFallbackApplied: true, + }); + }); + + it("blocks all group messages when policy is disabled", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "disabled", + defaultGroupPolicy: "open", + groupAllowFrom: ["zalo:123"], + senderId: "123", + }); + expect(decision).toMatchObject({ + allowed: false, + groupPolicy: "disabled", + reason: "disabled", + }); + }); + + it("blocks group messages on allowlist policy with empty allowlist", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: [], + senderId: "attacker", + }); + expect(decision).toMatchObject({ + allowed: false, + groupPolicy: "allowlist", + reason: "empty_allowlist", + }); + }); + + it("blocks sender not in group allowlist", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["zalo:victim-user-001"], + senderId: "attacker-user-999", + }); + expect(decision).toMatchObject({ + allowed: false, + groupPolicy: "allowlist", + reason: "sender_not_allowlisted", + }); + }); + + it("allows sender in group allowlist", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["zl:12345"], + senderId: "12345", + }); + expect(decision).toMatchObject({ + allowed: true, + groupPolicy: "allowlist", + reason: "allowed", + }); + }); + + it("allows any sender with wildcard allowlist", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["*"], + senderId: "random-user", + }); + expect(decision).toMatchObject({ + allowed: true, + groupPolicy: "allowlist", + reason: "allowed", + }); + }); + + it("allows all group senders on open policy", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "open", + defaultGroupPolicy: "allowlist", + groupAllowFrom: [], + senderId: "attacker-user-999", + }); + expect(decision).toMatchObject({ + allowed: true, + groupPolicy: "open", + reason: "allowed", + }); + }); +}); diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 47269635a442..71b3e4a15512 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -10,9 +10,12 @@ import { resolveSingleWebhookTarget, resolveSenderCommandAuthorization, resolveOutboundMediaUrls, + resolveDefaultGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, sendMediaWithLeadingCaption, resolveWebhookPath, resolveWebhookTargets, + warnMissingProviderGroupPolicyFallbackOnce, requestBodyErrorToText, } from "openclaw/plugin-sdk"; import type { ResolvedZaloAccount } from "./accounts.js"; @@ -62,6 +65,14 @@ const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25; type ZaloCoreRuntime = ReturnType; type WebhookRateLimitState = { count: number; windowStartMs: number }; +type ZaloGroupPolicy = "open" | "allowlist" | "disabled"; +type ZaloGroupAccessReason = "allowed" | "disabled" | "empty_allowlist" | "sender_not_allowlisted"; +type ZaloGroupAccessDecision = { + allowed: boolean; + groupPolicy: ZaloGroupPolicy; + providerMissingFallbackApplied: boolean; + reason: ZaloGroupAccessReason; +}; function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void { if (core.logging.shouldLogVerbose()) { @@ -80,6 +91,67 @@ function isSenderAllowed(senderId: string, allowFrom: string[]): boolean { }); } +function resolveZaloRuntimeGroupPolicy(params: { + providerConfigPresent: boolean; + groupPolicy?: ZaloGroupPolicy; + defaultGroupPolicy?: ZaloGroupPolicy; +}): { + groupPolicy: ZaloGroupPolicy; + providerMissingFallbackApplied: boolean; +} { + return resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + }); +} + +function evaluateZaloGroupAccess(params: { + providerConfigPresent: boolean; + configuredGroupPolicy?: ZaloGroupPolicy; + defaultGroupPolicy?: ZaloGroupPolicy; + groupAllowFrom: string[]; + senderId: string; +}): ZaloGroupAccessDecision { + const { groupPolicy, providerMissingFallbackApplied } = resolveZaloRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.configuredGroupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + }); + if (groupPolicy === "disabled") { + return { + allowed: false, + groupPolicy, + providerMissingFallbackApplied, + reason: "disabled", + }; + } + if (groupPolicy === "allowlist") { + if (params.groupAllowFrom.length === 0) { + return { + allowed: false, + groupPolicy, + providerMissingFallbackApplied, + reason: "empty_allowlist", + }; + } + if (!isSenderAllowed(params.senderId, params.groupAllowFrom)) { + return { + allowed: false, + groupPolicy, + providerMissingFallbackApplied, + reason: "sender_not_allowlisted", + }; + } + } + return { + allowed: true, + groupPolicy, + providerMissingFallbackApplied, + reason: "allowed", + }; +} + type WebhookTarget = { token: string; account: ResolvedZaloAccount; @@ -502,6 +574,42 @@ async function processMessageWithPipeline(params: { const dmPolicy = account.config.dmPolicy ?? "pairing"; const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v)); + const configuredGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v)); + const groupAllowFrom = + configuredGroupAllowFrom.length > 0 ? configuredGroupAllowFrom : configAllowFrom; + const defaultGroupPolicy = resolveDefaultGroupPolicy(config); + const groupAccess = isGroup + ? evaluateZaloGroupAccess({ + providerConfigPresent: config.channels?.zalo !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + groupAllowFrom, + senderId, + }) + : undefined; + if (groupAccess) { + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied: groupAccess.providerMissingFallbackApplied, + providerKey: "zalo", + accountId: account.accountId, + log: (message) => logVerbose(core, runtime, message), + }); + if (!groupAccess.allowed) { + if (groupAccess.reason === "disabled") { + logVerbose(core, runtime, `zalo: drop group ${chatId} (groupPolicy=disabled)`); + } else if (groupAccess.reason === "empty_allowlist") { + logVerbose( + core, + runtime, + `zalo: drop group ${chatId} (groupPolicy=allowlist, no groupAllowFrom)`, + ); + } else if (groupAccess.reason === "sender_not_allowlisted") { + logVerbose(core, runtime, `zalo: drop group sender ${senderId} (groupPolicy=allowlist)`); + } + return; + } + } + const rawBody = text?.trim() || (mediaPath ? "" : ""); const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({ cfg: config, @@ -818,3 +926,8 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise< return { stop }; } + +export const __testing = { + evaluateZaloGroupAccess, + resolveZaloRuntimeGroupPolicy, +}; diff --git a/extensions/zalo/src/types.ts b/extensions/zalo/src/types.ts index bcc43138f976..c17ea0cfc617 100644 --- a/extensions/zalo/src/types.ts +++ b/extensions/zalo/src/types.ts @@ -17,6 +17,10 @@ export type ZaloAccountConfig = { dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; /** Allowlist for DM senders (Zalo user IDs). */ allowFrom?: Array; + /** Group-message access policy. */ + groupPolicy?: "open" | "allowlist" | "disabled"; + /** Allowlist for group senders (falls back to allowFrom when unset). */ + groupAllowFrom?: Array; /** Max inbound media size in MB. */ mediaMaxMb?: number; /** Proxy URL for API requests. */ From 79e2328935aa1b6259702c4769dac1f18596e2fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:30:43 +0000 Subject: [PATCH 264/408] docs: update changelog for safe-bin hardening --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cf8e3f9fb72..4a46fdd9f855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ Docs: https://docs.openclaw.ai - Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so `dmPolicy: "allowlist"` with empty `allowedUserIds` rejects all senders instead of allowing unauthorized dispatch. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), preventing writable-dir binary shadowing from auto-satisfying safe-bin allowlist checks. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. This ships in the next npm release. Thanks @tdjackey for reporting. - Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. - Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. - Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. From 79a7b3d22ef92e36a4031093d80a0acb0d82f351 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:31:15 +0000 Subject: [PATCH 265/408] test(line): align tmp-root expectation after sandbox hardening --- CHANGELOG.md | 2 +- src/line/download.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a46fdd9f855..354b008b5aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ Docs: https://docs.openclaw.ai - Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. - Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch. - Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. -- Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. +- Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. This ships in the next npm release. Thanks @tdjackey for reporting. - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. - Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. This ships in the next npm release. Thanks @GCXWLP for reporting. - Zalo/Group policy: enforce sender authorization for group messages with `groupPolicy` + `groupAllowFrom` (fallback to `allowFrom`), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/src/line/download.test.ts b/src/line/download.test.ts index 2e64473b734e..677f20492001 100644 --- a/src/line/download.test.ts +++ b/src/line/download.test.ts @@ -1,7 +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 { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; const getMessageContentMock = vi.hoisted(() => vi.fn()); @@ -54,7 +54,7 @@ describe("downloadLineMedia", () => { expect(writtenPath).not.toContain(messageId); expect(writtenPath).not.toContain(".."); - const tmpRoot = path.resolve(os.tmpdir()); + const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir()); const rel = path.relative(tmpRoot, path.resolve(writtenPath)); expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); }); From 13a1c4639678a81cbedb02ad3aafb5e04716c090 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:32:13 +0000 Subject: [PATCH 266/408] fix(web-search): reduce provider auto-detect log noise --- src/agents/tools/web-search.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index d2da64e281ab..0c299f6be0fe 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -1,8 +1,8 @@ import { Type } from "@sinclair/typebox"; import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { logVerbose } from "../../globals.js"; import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js"; -import { defaultRuntime } from "../../runtime.js"; import { wrapWebContent } from "../../security/external-content.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import type { AnyAgentTool } from "./common.js"; @@ -353,7 +353,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE if (raw === "") { // 1. Brave if (resolveSearchApiKey(search)) { - defaultRuntime.log( + logVerbose( 'web_search: no provider configured, auto-detected "brave" from available API keys', ); return "brave"; @@ -361,7 +361,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE // 2. Gemini const geminiConfig = resolveGeminiConfig(search); if (resolveGeminiApiKey(geminiConfig)) { - defaultRuntime.log( + logVerbose( 'web_search: no provider configured, auto-detected "gemini" from available API keys', ); return "gemini"; @@ -369,7 +369,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE // 3. Kimi const kimiConfig = resolveKimiConfig(search); if (resolveKimiApiKey(kimiConfig)) { - defaultRuntime.log( + logVerbose( 'web_search: no provider configured, auto-detected "kimi" from available API keys', ); return "kimi"; @@ -378,7 +378,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE const perplexityConfig = resolvePerplexityConfig(search); const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig); if (perplexityKey) { - defaultRuntime.log( + logVerbose( 'web_search: no provider configured, auto-detected "perplexity" from available API keys', ); return "perplexity"; @@ -386,7 +386,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE // 5. Grok const grokConfig = resolveGrokConfig(search); if (resolveGrokApiKey(grokConfig)) { - defaultRuntime.log( + logVerbose( 'web_search: no provider configured, auto-detected "grok" from available API keys', ); return "grok"; From a2529c25ffb15eb2b3102cb06959d06f5d9a4054 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:33:02 +0000 Subject: [PATCH 267/408] test(matrix,discord,sandbox): expand breakage regression coverage --- .../matrix/src/matrix/monitor/events.test.ts | 51 +++++++++++++++++-- .../matrix/src/matrix/monitor/events.ts | 31 ++++++++--- .../matrix/src/matrix/send-queue.test.ts | 30 +++++++++++ src/agents/sandbox/fs-bridge.test.ts | 16 ++++++ .../monitor/message-handler.process.test.ts | 18 +++++++ 5 files changed, 135 insertions(+), 11 deletions(-) diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index dbd2245046d4..3754cfd178e7 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -16,13 +16,14 @@ describe("registerMatrixMonitorEvents", () => { sendReadReceiptMatrixMock.mockClear(); }); - function createHarness() { + function createHarness(options?: { getUserId?: ReturnType }) { const handlers = new Map void>(); + const getUserId = options?.getUserId ?? vi.fn().mockResolvedValue("@bot:example.org"); const client = { on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { handlers.set(event, handler); }), - getUserId: vi.fn().mockResolvedValue("@bot:example.org"), + getUserId, crypto: undefined, } as unknown as MatrixClient; @@ -49,7 +50,7 @@ describe("registerMatrixMonitorEvents", () => { throw new Error("missing room.message handler"); } - return { client, onRoomMessage, roomMessageHandler }; + return { client, getUserId, onRoomMessage, roomMessageHandler, logVerboseMessage }; } it("sends read receipt immediately for non-self messages", async () => { @@ -93,4 +94,48 @@ describe("registerMatrixMonitorEvents", () => { }); expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); }); + + it("caches self user id across messages", async () => { + const { getUserId, roomMessageHandler } = createHarness(); + const first = { event_id: "$e3", sender: "@alice:example.org" } as MatrixRawEvent; + const second = { event_id: "$e4", sender: "@bob:example.org" } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", first); + roomMessageHandler("!room:example.org", second); + + await vi.waitFor(() => { + expect(sendReadReceiptMatrixMock).toHaveBeenCalledTimes(2); + }); + expect(getUserId).toHaveBeenCalledTimes(1); + }); + + it("logs and continues when sending read receipt fails", async () => { + sendReadReceiptMatrixMock.mockRejectedValueOnce(new Error("network boom")); + const { roomMessageHandler, onRoomMessage, logVerboseMessage } = createHarness(); + const event = { event_id: "$e5", sender: "@alice:example.org" } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", event); + + await vi.waitFor(() => { + expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); + expect(logVerboseMessage).toHaveBeenCalledWith( + expect.stringContaining("matrix: early read receipt failed"), + ); + }); + }); + + it("skips read receipts if self-user lookup fails", async () => { + const { roomMessageHandler, onRoomMessage, getUserId } = createHarness({ + getUserId: vi.fn().mockRejectedValue(new Error("cannot resolve self")), + }); + const event = { event_id: "$e6", sender: "@alice:example.org" } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", event); + + await vi.waitFor(() => { + expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); + }); + expect(getUserId).toHaveBeenCalledTimes(1); + expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index ab548ef18c22..1f64f9558519 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -27,19 +27,34 @@ export function registerMatrixMonitorEvents(params: { } = params; let selfUserId: string | undefined; + let selfUserIdLookup: Promise | undefined; + const resolveSelfUserId = async (): Promise => { + if (selfUserId) { + return selfUserId; + } + if (!selfUserIdLookup) { + selfUserIdLookup = client + .getUserId() + .then((userId) => { + selfUserId = userId; + return userId; + }) + .catch(() => undefined) + .finally(() => { + if (!selfUserId) { + selfUserIdLookup = undefined; + } + }); + } + return await selfUserIdLookup; + }; client.on("room.message", (roomId: string, event: MatrixRawEvent) => { const eventId = event?.event_id; const senderId = event?.sender; if (eventId && senderId) { void (async () => { - if (!selfUserId) { - try { - selfUserId = await client.getUserId(); - } catch { - return; - } - } - if (senderId === selfUserId) { + const currentSelfUserId = await resolveSelfUserId(); + if (!currentSelfUserId || senderId === currentSelfUserId) { return; } await sendReadReceiptMatrix(roomId, eventId, client).catch((err) => { diff --git a/extensions/matrix/src/matrix/send-queue.test.ts b/extensions/matrix/src/matrix/send-queue.test.ts index 34e6e30166c0..508a01d301e6 100644 --- a/extensions/matrix/src/matrix/send-queue.test.ts +++ b/extensions/matrix/src/matrix/send-queue.test.ts @@ -86,4 +86,34 @@ describe("enqueueSend", () => { await vi.advanceTimersByTimeAsync(150); await expect(second).resolves.toBe("ok"); }); + + it("continues queued work when the head task fails", async () => { + const gate = deferred(); + const events: string[] = []; + + const first = enqueueSend("!room:example.org", async () => { + events.push("start1"); + await gate.promise; + throw new Error("boom"); + }).then( + () => ({ ok: true as const }), + (error) => ({ ok: false as const, error }), + ); + const second = enqueueSend("!room:example.org", async () => { + events.push("start2"); + return "two"; + }); + + await vi.advanceTimersByTimeAsync(150); + expect(events).toEqual(["start1"]); + + gate.resolve(); + const firstResult = await first; + expect(firstResult.ok).toBe(false); + expect(firstResult.error).toBeInstanceOf(Error); + + await vi.advanceTimersByTimeAsync(150); + await expect(second).resolves.toBe("two"); + expect(events).toEqual(["start1", "start2"]); + }); }); diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts index 982d3cbf6a55..a4ae727b330b 100644 --- a/src/agents/sandbox/fs-bridge.test.ts +++ b/src/agents/sandbox/fs-bridge.test.ts @@ -91,6 +91,22 @@ describe("sandbox fs bridge shell compatibility", () => { expect(canonicalScript).toBeDefined(); // "; " joining can create "do; cmd", which is invalid in POSIX sh. expect(canonicalScript).not.toMatch(/\bdo;/); + // Keep command on the next line after "do" for POSIX-sh safety. + expect(canonicalScript).toMatch(/\bdo\n\s*parent=/); + }); + + it("reads inbound media-style filenames with triple-dash ids", async () => { + const bridge = createSandboxFsBridge({ sandbox: createSandbox() }); + const inboundPath = "media/inbound/file_1095---f00a04a2-99a0-4d98-99b0-dfe61c5a4198.ogg"; + + await bridge.readFile({ filePath: inboundPath }); + + const readCall = mockedExecDockerRaw.mock.calls.find(([args]) => + String(args[5] ?? "").includes('cat -- "$1"'), + ); + expect(readCall).toBeDefined(); + const readPath = String(readCall?.[0].at(-1) ?? ""); + expect(readPath).toContain("file_1095---"); }); it("resolves bind-mounted absolute container paths for reads", async () => { diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 79af5ffa477d..60eade41f3db 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -444,6 +444,24 @@ describe("processDiscordMessage draft streaming", () => { expect(deliverDiscordReply).not.toHaveBeenCalled(); }); + it("suppresses reasoning-tagged final payload delivery to Discord", async () => { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.dispatcher.sendFinalReply({ + text: "Reasoning:\nthis should stay internal", + isReasoning: true, + } as never); + return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; + }); + + const ctx = await createBaseContext({ discordConfig: { streamMode: "off" } }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + expect(deliverDiscordReply).not.toHaveBeenCalled(); + expect(editMessageDiscord).not.toHaveBeenCalled(); + }); + it("delivers non-reasoning block payloads to Discord", async () => { dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { await params?.dispatcher.sendBlockReply({ text: "hello from block stream" }); From 58309fd8d90d69e48754ce8795218dab4c129f27 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:37:45 +0000 Subject: [PATCH 268/408] refactor(matrix,tests): extract helpers and inject send-queue timing --- .../matrix/src/matrix/monitor/events.ts | 49 ++++++++++--------- .../matrix/src/matrix/send-queue.test.ts | 47 ++++++++++++++---- extensions/matrix/src/matrix/send-queue.ts | 17 +++++-- src/agents/sandbox/fs-bridge.test.ts | 34 +++++++++---- .../monitor/message-handler.process.test.ts | 7 ++- 5 files changed, 108 insertions(+), 46 deletions(-) diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index 1f64f9558519..279517d521d8 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -5,30 +5,11 @@ import { sendReadReceiptMatrix } from "../send.js"; import type { MatrixRawEvent } from "./types.js"; import { EventType } from "./types.js"; -export function registerMatrixMonitorEvents(params: { - client: MatrixClient; - auth: MatrixAuth; - logVerboseMessage: (message: string) => void; - warnedEncryptedRooms: Set; - warnedCryptoMissingRooms: Set; - logger: RuntimeLogger; - formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"]; - onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise; -}): void { - const { - client, - auth, - logVerboseMessage, - warnedEncryptedRooms, - warnedCryptoMissingRooms, - logger, - formatNativeDependencyHint, - onRoomMessage, - } = params; - +function createSelfUserIdResolver(client: Pick) { let selfUserId: string | undefined; let selfUserIdLookup: Promise | undefined; - const resolveSelfUserId = async (): Promise => { + + return async (): Promise => { if (selfUserId) { return selfUserId; } @@ -48,6 +29,30 @@ export function registerMatrixMonitorEvents(params: { } return await selfUserIdLookup; }; +} + +export function registerMatrixMonitorEvents(params: { + client: MatrixClient; + auth: MatrixAuth; + logVerboseMessage: (message: string) => void; + warnedEncryptedRooms: Set; + warnedCryptoMissingRooms: Set; + logger: RuntimeLogger; + formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"]; + onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise; +}): void { + const { + client, + auth, + logVerboseMessage, + warnedEncryptedRooms, + warnedCryptoMissingRooms, + logger, + formatNativeDependencyHint, + onRoomMessage, + } = params; + + const resolveSelfUserId = createSelfUserIdResolver(client); client.on("room.message", (roomId: string, event: MatrixRawEvent) => { const eventId = event?.event_id; const senderId = event?.sender; diff --git a/extensions/matrix/src/matrix/send-queue.test.ts b/extensions/matrix/src/matrix/send-queue.test.ts index 508a01d301e6..bc90c5f50ab5 100644 --- a/extensions/matrix/src/matrix/send-queue.test.ts +++ b/extensions/matrix/src/matrix/send-queue.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { enqueueSend } from "./send-queue.js"; +import { DEFAULT_SEND_GAP_MS, enqueueSend } from "./send-queue.js"; function deferred() { let resolve!: (value: T | PromiseLike) => void; @@ -36,15 +36,15 @@ describe("enqueueSend", () => { return "two"; }); - await vi.advanceTimersByTimeAsync(150); + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); expect(events).toEqual(["start1"]); - await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS * 2); expect(events).toEqual(["start1"]); gate.resolve(); await first; - await vi.advanceTimersByTimeAsync(149); + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS - 1); expect(events).toEqual(["start1", "end1"]); await vi.advanceTimersByTimeAsync(1); await second; @@ -63,7 +63,7 @@ describe("enqueueSend", () => { return "b"; }); - await vi.advanceTimersByTimeAsync(150); + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); await Promise.all([a, b]); expect(events.sort()).toEqual(["a", "b"]); }); @@ -76,14 +76,14 @@ describe("enqueueSend", () => { (error) => ({ ok: false as const, error }), ); - await vi.advanceTimersByTimeAsync(150); + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); const firstResult = await first; expect(firstResult.ok).toBe(false); expect(firstResult.error).toBeInstanceOf(Error); expect((firstResult.error as Error).message).toBe("boom"); const second = enqueueSend("!room:example.org", async () => "ok"); - await vi.advanceTimersByTimeAsync(150); + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); await expect(second).resolves.toBe("ok"); }); @@ -104,7 +104,7 @@ describe("enqueueSend", () => { return "two"; }); - await vi.advanceTimersByTimeAsync(150); + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); expect(events).toEqual(["start1"]); gate.resolve(); @@ -112,8 +112,37 @@ describe("enqueueSend", () => { expect(firstResult.ok).toBe(false); expect(firstResult.error).toBeInstanceOf(Error); - await vi.advanceTimersByTimeAsync(150); + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); await expect(second).resolves.toBe("two"); expect(events).toEqual(["start1", "start2"]); }); + + it("supports custom gap and delay injection", async () => { + const events: string[] = []; + const delayFn = vi.fn(async (_ms: number) => {}); + + const first = enqueueSend( + "!room:example.org", + async () => { + events.push("first"); + return "one"; + }, + { gapMs: 7, delayFn }, + ); + const second = enqueueSend( + "!room:example.org", + async () => { + events.push("second"); + return "two"; + }, + { gapMs: 7, delayFn }, + ); + + await expect(first).resolves.toBe("one"); + await expect(second).resolves.toBe("two"); + expect(events).toEqual(["first", "second"]); + expect(delayFn).toHaveBeenCalledTimes(2); + expect(delayFn).toHaveBeenNthCalledWith(1, 7); + expect(delayFn).toHaveBeenNthCalledWith(2, 7); + }); }); diff --git a/extensions/matrix/src/matrix/send-queue.ts b/extensions/matrix/src/matrix/send-queue.ts index 0d5e43b40e21..daf5e40931e8 100644 --- a/extensions/matrix/src/matrix/send-queue.ts +++ b/extensions/matrix/src/matrix/send-queue.ts @@ -1,15 +1,26 @@ -const SEND_GAP_MS = 150; +export const DEFAULT_SEND_GAP_MS = 150; + +type MatrixSendQueueOptions = { + gapMs?: number; + delayFn?: (ms: number) => Promise; +}; // Serialize sends per room to preserve Matrix delivery order. const roomQueues = new Map>(); -export async function enqueueSend(roomId: string, fn: () => Promise): Promise { +export async function enqueueSend( + roomId: string, + fn: () => Promise, + options?: MatrixSendQueueOptions, +): Promise { + const gapMs = options?.gapMs ?? DEFAULT_SEND_GAP_MS; + const delayFn = options?.delayFn ?? delay; const previous = roomQueues.get(roomId) ?? Promise.resolve(); const next = previous .catch(() => {}) .then(async () => { - await delay(SEND_GAP_MS); + await delayFn(gapMs); return await fn(); }); diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts index a4ae727b330b..98744f3562d0 100644 --- a/src/agents/sandbox/fs-bridge.test.ts +++ b/src/agents/sandbox/fs-bridge.test.ts @@ -14,6 +14,22 @@ import type { SandboxContext } from "./types.js"; const mockedExecDockerRaw = vi.mocked(execDockerRaw); +function getDockerScript(args: string[]): string { + return String(args[5] ?? ""); +} + +function getDockerPathArg(args: string[]): string { + return String(args.at(-1) ?? ""); +} + +function getScriptsFromCalls(): string[] { + return mockedExecDockerRaw.mock.calls.map(([args]) => getDockerScript(args)); +} + +function findCallByScriptFragment(fragment: string) { + return mockedExecDockerRaw.mock.calls.find(([args]) => getDockerScript(args).includes(fragment)); +} + function createSandbox(overrides?: Partial): SandboxContext { return createSandboxTestContext({ overrides: { @@ -31,7 +47,7 @@ describe("sandbox fs bridge shell compatibility", () => { beforeEach(() => { mockedExecDockerRaw.mockClear(); mockedExecDockerRaw.mockImplementation(async (args) => { - const script = args[5] ?? ""; + const script = getDockerScript(args); if (script.includes('readlink -f -- "$cursor"')) { return { stdout: Buffer.from(`${String(args.at(-2) ?? "")}\n`), @@ -73,7 +89,7 @@ describe("sandbox fs bridge shell compatibility", () => { expect(mockedExecDockerRaw).toHaveBeenCalled(); - const scripts = mockedExecDockerRaw.mock.calls.map(([args]) => args[5] ?? ""); + const scripts = getScriptsFromCalls(); const executables = mockedExecDockerRaw.mock.calls.map(([args]) => args[3] ?? ""); expect(executables.every((shell) => shell === "sh")).toBe(true); @@ -86,7 +102,7 @@ describe("sandbox fs bridge shell compatibility", () => { await bridge.readFile({ filePath: "a.txt" }); - const scripts = mockedExecDockerRaw.mock.calls.map(([args]) => args[5] ?? ""); + const scripts = getScriptsFromCalls(); const canonicalScript = scripts.find((script) => script.includes("allow_final")); expect(canonicalScript).toBeDefined(); // "; " joining can create "do; cmd", which is invalid in POSIX sh. @@ -101,11 +117,9 @@ describe("sandbox fs bridge shell compatibility", () => { await bridge.readFile({ filePath: inboundPath }); - const readCall = mockedExecDockerRaw.mock.calls.find(([args]) => - String(args[5] ?? "").includes('cat -- "$1"'), - ); + const readCall = findCallByScriptFragment('cat -- "$1"'); expect(readCall).toBeDefined(); - const readPath = String(readCall?.[0].at(-1) ?? ""); + const readPath = readCall ? getDockerPathArg(readCall[0]) : ""; expect(readPath).toContain("file_1095---"); }); @@ -124,7 +138,7 @@ describe("sandbox fs bridge shell compatibility", () => { expect(args).toEqual( expect.arrayContaining(["moltbot-sbx-test", "sh", "-c", 'set -eu; cat -- "$1"']), ); - expect(args.at(-1)).toBe("/workspace-two/README.md"); + expect(getDockerPathArg(args)).toBe("/workspace-two/README.md"); }); it("blocks writes into read-only bind mounts", async () => { @@ -166,7 +180,7 @@ describe("sandbox fs bridge shell compatibility", () => { it("rejects container-canonicalized paths outside allowed mounts", async () => { mockedExecDockerRaw.mockImplementation(async (args) => { - const script = args[5] ?? ""; + const script = getDockerScript(args); if (script.includes('readlink -f -- "$cursor"')) { return { stdout: Buffer.from("/etc/passwd\n"), @@ -190,7 +204,7 @@ describe("sandbox fs bridge shell compatibility", () => { const bridge = createSandboxFsBridge({ sandbox: createSandbox() }); await expect(bridge.readFile({ filePath: "a.txt" })).rejects.toThrow(/escapes allowed mounts/i); - const scripts = mockedExecDockerRaw.mock.calls.map(([args]) => args[5] ?? ""); + const scripts = getScriptsFromCalls(); expect(scripts.some((script) => script.includes('cat -- "$1"'))).toBe(false); }); }); diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 60eade41f3db..750eab43b74e 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -35,7 +35,10 @@ type DispatchInboundParams = { text?: string; isReasoning?: boolean; }) => boolean | Promise; - sendFinalReply: (payload: { text?: string }) => boolean | Promise; + sendFinalReply: (payload: { + text?: string; + isReasoning?: boolean; + }) => boolean | Promise; }; replyOptions?: { onReasoningStream?: () => Promise | void; @@ -449,7 +452,7 @@ describe("processDiscordMessage draft streaming", () => { await params?.dispatcher.sendFinalReply({ text: "Reasoning:\nthis should stay internal", isReasoning: true, - } as never); + }); return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); From 453664f09d0911cc1829d22c63f664f0ad2c8c10 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:40:21 +0000 Subject: [PATCH 269/408] refactor(zalo): split monitor access and webhook logic --- extensions/zalo/src/group-access.ts | 48 ++++ extensions/zalo/src/monitor.ts | 312 ++----------------------- extensions/zalo/src/monitor.webhook.ts | 219 +++++++++++++++++ src/plugin-sdk/allow-from.test.ts | 33 ++- src/plugin-sdk/allow-from.ts | 19 ++ src/plugin-sdk/group-access.test.ts | 69 ++++++ src/plugin-sdk/group-access.ts | 64 +++++ src/plugin-sdk/index.ts | 11 +- 8 files changed, 486 insertions(+), 289 deletions(-) create mode 100644 extensions/zalo/src/group-access.ts create mode 100644 extensions/zalo/src/monitor.webhook.ts create mode 100644 src/plugin-sdk/group-access.test.ts create mode 100644 src/plugin-sdk/group-access.ts diff --git a/extensions/zalo/src/group-access.ts b/extensions/zalo/src/group-access.ts new file mode 100644 index 000000000000..7acd1997096b --- /dev/null +++ b/extensions/zalo/src/group-access.ts @@ -0,0 +1,48 @@ +import type { GroupPolicy, SenderGroupAccessDecision } from "openclaw/plugin-sdk"; +import { + evaluateSenderGroupAccess, + isNormalizedSenderAllowed, + resolveOpenProviderRuntimeGroupPolicy, +} from "openclaw/plugin-sdk"; + +const ZALO_ALLOW_FROM_PREFIX_RE = /^(zalo|zl):/i; + +export function isZaloSenderAllowed(senderId: string, allowFrom: string[]): boolean { + return isNormalizedSenderAllowed({ + senderId, + allowFrom, + stripPrefixRe: ZALO_ALLOW_FROM_PREFIX_RE, + }); +} + +export function resolveZaloRuntimeGroupPolicy(params: { + providerConfigPresent: boolean; + groupPolicy?: GroupPolicy; + defaultGroupPolicy?: GroupPolicy; +}): { + groupPolicy: GroupPolicy; + providerMissingFallbackApplied: boolean; +} { + return resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + }); +} + +export function evaluateZaloGroupAccess(params: { + providerConfigPresent: boolean; + configuredGroupPolicy?: GroupPolicy; + defaultGroupPolicy?: GroupPolicy; + groupAllowFrom: string[]; + senderId: string; +}): SenderGroupAccessDecision { + return evaluateSenderGroupAccess({ + providerConfigPresent: params.providerConfigPresent, + configuredGroupPolicy: params.configuredGroupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + groupAllowFrom: params.groupAllowFrom, + senderId: params.senderId, + isSenderAllowed: isZaloSenderAllowed, + }); +} diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 71b3e4a15512..76e656af7de1 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,22 +1,13 @@ -import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk"; import { - createDedupeCache, createReplyPrefixOptions, - readJsonBodyWithLimit, - registerWebhookTarget, - rejectNonPostWebhookRequest, - resolveSingleWebhookTarget, resolveSenderCommandAuthorization, resolveOutboundMediaUrls, resolveDefaultGroupPolicy, - resolveOpenProviderRuntimeGroupPolicy, sendMediaWithLeadingCaption, resolveWebhookPath, - resolveWebhookTargets, warnMissingProviderGroupPolicyFallbackOnce, - requestBodyErrorToText, } from "openclaw/plugin-sdk"; import type { ResolvedZaloAccount } from "./accounts.js"; import { @@ -30,6 +21,16 @@ import { type ZaloMessage, type ZaloUpdate, } from "./api.js"; +import { + evaluateZaloGroupAccess, + isZaloSenderAllowed, + resolveZaloRuntimeGroupPolicy, +} from "./group-access.js"; +import { + handleZaloWebhookRequest as handleZaloWebhookRequestInternal, + registerZaloWebhookTarget as registerZaloWebhookTargetInternal, + type ZaloWebhookTarget, +} from "./monitor.webhook.js"; import { resolveZaloProxyFetch } from "./proxy.js"; import { getZaloRuntime } from "./runtime.js"; @@ -58,21 +59,8 @@ export type ZaloMonitorResult = { const ZALO_TEXT_LIMIT = 2000; const DEFAULT_MEDIA_MAX_MB = 5; -const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000; -const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120; -const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000; -const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25; type ZaloCoreRuntime = ReturnType; -type WebhookRateLimitState = { count: number; windowStartMs: number }; -type ZaloGroupPolicy = "open" | "allowlist" | "disabled"; -type ZaloGroupAccessReason = "allowed" | "disabled" | "empty_allowlist" | "sender_not_allowlisted"; -type ZaloGroupAccessDecision = { - allowed: boolean; - groupPolicy: ZaloGroupPolicy; - providerMissingFallbackApplied: boolean; - reason: ZaloGroupAccessReason; -}; function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void { if (core.logging.shouldLogVerbose()) { @@ -80,277 +68,27 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str } } -function isSenderAllowed(senderId: string, allowFrom: string[]): boolean { - if (allowFrom.includes("*")) { - return true; - } - const normalizedSenderId = senderId.toLowerCase(); - return allowFrom.some((entry) => { - const normalized = entry.toLowerCase().replace(/^(zalo|zl):/i, ""); - return normalized === normalizedSenderId; - }); -} - -function resolveZaloRuntimeGroupPolicy(params: { - providerConfigPresent: boolean; - groupPolicy?: ZaloGroupPolicy; - defaultGroupPolicy?: ZaloGroupPolicy; -}): { - groupPolicy: ZaloGroupPolicy; - providerMissingFallbackApplied: boolean; -} { - return resolveOpenProviderRuntimeGroupPolicy({ - providerConfigPresent: params.providerConfigPresent, - groupPolicy: params.groupPolicy, - defaultGroupPolicy: params.defaultGroupPolicy, - }); -} - -function evaluateZaloGroupAccess(params: { - providerConfigPresent: boolean; - configuredGroupPolicy?: ZaloGroupPolicy; - defaultGroupPolicy?: ZaloGroupPolicy; - groupAllowFrom: string[]; - senderId: string; -}): ZaloGroupAccessDecision { - const { groupPolicy, providerMissingFallbackApplied } = resolveZaloRuntimeGroupPolicy({ - providerConfigPresent: params.providerConfigPresent, - groupPolicy: params.configuredGroupPolicy, - defaultGroupPolicy: params.defaultGroupPolicy, - }); - if (groupPolicy === "disabled") { - return { - allowed: false, - groupPolicy, - providerMissingFallbackApplied, - reason: "disabled", - }; - } - if (groupPolicy === "allowlist") { - if (params.groupAllowFrom.length === 0) { - return { - allowed: false, - groupPolicy, - providerMissingFallbackApplied, - reason: "empty_allowlist", - }; - } - if (!isSenderAllowed(params.senderId, params.groupAllowFrom)) { - return { - allowed: false, - groupPolicy, - providerMissingFallbackApplied, - reason: "sender_not_allowlisted", - }; - } - } - return { - allowed: true, - groupPolicy, - providerMissingFallbackApplied, - reason: "allowed", - }; -} - -type WebhookTarget = { - token: string; - account: ResolvedZaloAccount; - config: OpenClawConfig; - runtime: ZaloRuntimeEnv; - core: ZaloCoreRuntime; - secret: string; - path: string; - mediaMaxMb: number; - statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; - fetcher?: ZaloFetch; -}; - -const webhookTargets = new Map(); -const webhookRateLimits = new Map(); -const recentWebhookEvents = createDedupeCache({ - ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS, - maxSize: 5000, -}); -const webhookStatusCounters = new Map(); - -function isJsonContentType(value: string | string[] | undefined): boolean { - const first = Array.isArray(value) ? value[0] : value; - if (!first) { - return false; - } - const mediaType = first.split(";", 1)[0]?.trim().toLowerCase(); - return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json")); -} - -function timingSafeEquals(left: string, right: string): boolean { - const leftBuffer = Buffer.from(left); - const rightBuffer = Buffer.from(right); - - if (leftBuffer.length !== rightBuffer.length) { - const length = Math.max(1, leftBuffer.length, rightBuffer.length); - const paddedLeft = Buffer.alloc(length); - const paddedRight = Buffer.alloc(length); - leftBuffer.copy(paddedLeft); - rightBuffer.copy(paddedRight); - timingSafeEqual(paddedLeft, paddedRight); - return false; - } - - return timingSafeEqual(leftBuffer, rightBuffer); -} - -function isWebhookRateLimited(key: string, nowMs: number): boolean { - const state = webhookRateLimits.get(key); - if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) { - webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs }); - return false; - } - - state.count += 1; - if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) { - return true; - } - return false; -} - -function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean { - const messageId = update.message?.message_id; - if (!messageId) { - return false; - } - const key = `${update.event_name}:${messageId}`; - return recentWebhookEvents.check(key, nowMs); -} - -function recordWebhookStatus( - runtime: ZaloRuntimeEnv | undefined, - path: string, - statusCode: number, -): void { - if (![400, 401, 408, 413, 415, 429].includes(statusCode)) { - return; - } - const key = `${path}:${statusCode}`; - const next = (webhookStatusCounters.get(key) ?? 0) + 1; - webhookStatusCounters.set(key, next); - if (next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0) { - runtime?.log?.( - `[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`, - ); - } -} - -export function registerZaloWebhookTarget(target: WebhookTarget): () => void { - return registerWebhookTarget(webhookTargets, target).unregister; +export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void { + return registerZaloWebhookTargetInternal(target); } export async function handleZaloWebhookRequest( req: IncomingMessage, res: ServerResponse, ): Promise { - const resolved = resolveWebhookTargets(req, webhookTargets); - if (!resolved) { - return false; - } - const { targets } = resolved; - - if (rejectNonPostWebhookRequest(req, res)) { - return true; - } - - const headerToken = String(req.headers["x-bot-api-secret-token"] ?? ""); - const matchedTarget = resolveSingleWebhookTarget(targets, (entry) => - timingSafeEquals(entry.secret, headerToken), - ); - if (matchedTarget.kind === "none") { - res.statusCode = 401; - res.end("unauthorized"); - recordWebhookStatus(targets[0]?.runtime, req.url ?? "", res.statusCode); - return true; - } - if (matchedTarget.kind === "ambiguous") { - res.statusCode = 401; - res.end("ambiguous webhook target"); - recordWebhookStatus(targets[0]?.runtime, req.url ?? "", res.statusCode); - return true; - } - const target = matchedTarget.target; - const path = req.url ?? ""; - const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`; - const nowMs = Date.now(); - - if (isWebhookRateLimited(rateLimitKey, nowMs)) { - res.statusCode = 429; - res.end("Too Many Requests"); - recordWebhookStatus(target.runtime, path, res.statusCode); - return true; - } - - if (!isJsonContentType(req.headers["content-type"])) { - res.statusCode = 415; - res.end("Unsupported Media Type"); - recordWebhookStatus(target.runtime, path, res.statusCode); - return true; - } - - const body = await readJsonBodyWithLimit(req, { - maxBytes: 1024 * 1024, - timeoutMs: 30_000, - emptyObjectOnEmpty: false, - }); - if (!body.ok) { - res.statusCode = - body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400; - const message = - body.code === "PAYLOAD_TOO_LARGE" - ? requestBodyErrorToText("PAYLOAD_TOO_LARGE") - : body.code === "REQUEST_BODY_TIMEOUT" - ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT") - : "Bad Request"; - res.end(message); - recordWebhookStatus(target.runtime, path, res.statusCode); - return true; - } - - // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result } - const raw = body.value; - const record = raw && typeof raw === "object" ? (raw as Record) : null; - const update: ZaloUpdate | undefined = - record && record.ok === true && record.result - ? (record.result as ZaloUpdate) - : ((record as ZaloUpdate | null) ?? undefined); - - if (!update?.event_name) { - res.statusCode = 400; - res.end("Bad Request"); - recordWebhookStatus(target.runtime, path, res.statusCode); - return true; - } - - if (isReplayEvent(update, nowMs)) { - res.statusCode = 200; - res.end("ok"); - return true; - } - - target.statusSink?.({ lastInboundAt: Date.now() }); - processUpdate( - update, - target.token, - target.account, - target.config, - target.runtime, - target.core, - target.mediaMaxMb, - target.statusSink, - target.fetcher, - ).catch((err) => { - target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`); + return handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => { + await processUpdate( + update, + target.token, + target.account, + target.config, + target.runtime, + target.core as ZaloCoreRuntime, + target.mediaMaxMb, + target.statusSink, + target.fetcher, + ); }); - - res.statusCode = 200; - res.end("ok"); - return true; } function startPollingLoop(params: { @@ -618,7 +356,7 @@ async function processMessageWithPipeline(params: { dmPolicy, configuredAllowFrom: configAllowFrom, senderId, - isSenderAllowed, + isSenderAllowed: isZaloSenderAllowed, readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"), shouldComputeCommandAuthorized: (body, cfg) => core.channel.commands.shouldComputeCommandAuthorized(body, cfg), diff --git a/extensions/zalo/src/monitor.webhook.ts b/extensions/zalo/src/monitor.webhook.ts new file mode 100644 index 000000000000..dd2b0c655850 --- /dev/null +++ b/extensions/zalo/src/monitor.webhook.ts @@ -0,0 +1,219 @@ +import { timingSafeEqual } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { + createDedupeCache, + readJsonBodyWithLimit, + registerWebhookTarget, + rejectNonPostWebhookRequest, + requestBodyErrorToText, + resolveSingleWebhookTarget, + resolveWebhookTargets, +} from "openclaw/plugin-sdk"; +import type { ResolvedZaloAccount } from "./accounts.js"; +import type { ZaloFetch, ZaloUpdate } from "./api.js"; +import type { ZaloRuntimeEnv } from "./monitor.js"; + +type WebhookRateLimitState = { count: number; windowStartMs: number }; + +const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000; +const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120; +const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000; +const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25; + +export type ZaloWebhookTarget = { + token: string; + account: ResolvedZaloAccount; + config: OpenClawConfig; + runtime: ZaloRuntimeEnv; + core: unknown; + secret: string; + path: string; + mediaMaxMb: number; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + fetcher?: ZaloFetch; +}; + +export type ZaloWebhookProcessUpdate = (params: { + update: ZaloUpdate; + target: ZaloWebhookTarget; +}) => Promise; + +const webhookTargets = new Map(); +const webhookRateLimits = new Map(); +const recentWebhookEvents = createDedupeCache({ + ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS, + maxSize: 5000, +}); +const webhookStatusCounters = new Map(); + +function isJsonContentType(value: string | string[] | undefined): boolean { + const first = Array.isArray(value) ? value[0] : value; + if (!first) { + return false; + } + const mediaType = first.split(";", 1)[0]?.trim().toLowerCase(); + return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json")); +} + +function timingSafeEquals(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + + if (leftBuffer.length !== rightBuffer.length) { + const length = Math.max(1, leftBuffer.length, rightBuffer.length); + const paddedLeft = Buffer.alloc(length); + const paddedRight = Buffer.alloc(length); + leftBuffer.copy(paddedLeft); + rightBuffer.copy(paddedRight); + timingSafeEqual(paddedLeft, paddedRight); + return false; + } + + return timingSafeEqual(leftBuffer, rightBuffer); +} + +function isWebhookRateLimited(key: string, nowMs: number): boolean { + const state = webhookRateLimits.get(key); + if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) { + webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs }); + return false; + } + + state.count += 1; + if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) { + return true; + } + return false; +} + +function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean { + const messageId = update.message?.message_id; + if (!messageId) { + return false; + } + const key = `${update.event_name}:${messageId}`; + return recentWebhookEvents.check(key, nowMs); +} + +function recordWebhookStatus( + runtime: ZaloRuntimeEnv | undefined, + path: string, + statusCode: number, +): void { + if (![400, 401, 408, 413, 415, 429].includes(statusCode)) { + return; + } + const key = `${path}:${statusCode}`; + const next = (webhookStatusCounters.get(key) ?? 0) + 1; + webhookStatusCounters.set(key, next); + if (next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0) { + runtime?.log?.( + `[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`, + ); + } +} + +export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void { + return registerWebhookTarget(webhookTargets, target).unregister; +} + +export async function handleZaloWebhookRequest( + req: IncomingMessage, + res: ServerResponse, + processUpdate: ZaloWebhookProcessUpdate, +): Promise { + const resolved = resolveWebhookTargets(req, webhookTargets); + if (!resolved) { + return false; + } + const { targets } = resolved; + + if (rejectNonPostWebhookRequest(req, res)) { + return true; + } + + const headerToken = String(req.headers["x-bot-api-secret-token"] ?? ""); + const matchedTarget = resolveSingleWebhookTarget(targets, (entry) => + timingSafeEquals(entry.secret, headerToken), + ); + if (matchedTarget.kind === "none") { + res.statusCode = 401; + res.end("unauthorized"); + recordWebhookStatus(targets[0]?.runtime, req.url ?? "", res.statusCode); + return true; + } + if (matchedTarget.kind === "ambiguous") { + res.statusCode = 401; + res.end("ambiguous webhook target"); + recordWebhookStatus(targets[0]?.runtime, req.url ?? "", res.statusCode); + return true; + } + const target = matchedTarget.target; + const path = req.url ?? ""; + const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`; + const nowMs = Date.now(); + + if (isWebhookRateLimited(rateLimitKey, nowMs)) { + res.statusCode = 429; + res.end("Too Many Requests"); + recordWebhookStatus(target.runtime, path, res.statusCode); + return true; + } + + if (!isJsonContentType(req.headers["content-type"])) { + res.statusCode = 415; + res.end("Unsupported Media Type"); + recordWebhookStatus(target.runtime, path, res.statusCode); + return true; + } + + const body = await readJsonBodyWithLimit(req, { + maxBytes: 1024 * 1024, + timeoutMs: 30_000, + emptyObjectOnEmpty: false, + }); + if (!body.ok) { + res.statusCode = + body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400; + const message = + body.code === "PAYLOAD_TOO_LARGE" + ? requestBodyErrorToText("PAYLOAD_TOO_LARGE") + : body.code === "REQUEST_BODY_TIMEOUT" + ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT") + : "Bad Request"; + res.end(message); + recordWebhookStatus(target.runtime, path, res.statusCode); + return true; + } + + // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }. + const raw = body.value; + const record = raw && typeof raw === "object" ? (raw as Record) : null; + const update: ZaloUpdate | undefined = + record && record.ok === true && record.result + ? (record.result as ZaloUpdate) + : ((record as ZaloUpdate | null) ?? undefined); + + if (!update?.event_name) { + res.statusCode = 400; + res.end("Bad Request"); + recordWebhookStatus(target.runtime, path, res.statusCode); + return true; + } + + if (isReplayEvent(update, nowMs)) { + res.statusCode = 200; + res.end("ok"); + return true; + } + + target.statusSink?.({ lastInboundAt: Date.now() }); + processUpdate({ update, target }).catch((err) => { + target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`); + }); + + res.statusCode = 200; + res.end("ok"); + return true; +} diff --git a/src/plugin-sdk/allow-from.test.ts b/src/plugin-sdk/allow-from.test.ts index cc69376c5fe5..8ad13fe98f6c 100644 --- a/src/plugin-sdk/allow-from.test.ts +++ b/src/plugin-sdk/allow-from.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isAllowedParsedChatSender } from "./allow-from.js"; +import { isAllowedParsedChatSender, isNormalizedSenderAllowed } from "./allow-from.js"; function parseAllowTarget( entry: string, @@ -71,3 +71,34 @@ describe("isAllowedParsedChatSender", () => { expect(allowed).toBe(true); }); }); + +describe("isNormalizedSenderAllowed", () => { + it("allows wildcard", () => { + expect( + isNormalizedSenderAllowed({ + senderId: "attacker", + allowFrom: ["*"], + }), + ).toBe(true); + }); + + it("normalizes case and strips prefixes", () => { + expect( + isNormalizedSenderAllowed({ + senderId: "12345", + allowFrom: ["ZALO:12345", "zl:777"], + stripPrefixRe: /^(zalo|zl):/i, + }), + ).toBe(true); + }); + + it("rejects when sender is missing", () => { + expect( + isNormalizedSenderAllowed({ + senderId: "999", + allowFrom: ["zl:12345"], + stripPrefixRe: /^(zalo|zl):/i, + }), + ).toBe(false); + }); +}); diff --git a/src/plugin-sdk/allow-from.ts b/src/plugin-sdk/allow-from.ts index 39ef277876ab..93c3d52c7125 100644 --- a/src/plugin-sdk/allow-from.ts +++ b/src/plugin-sdk/allow-from.ts @@ -9,6 +9,25 @@ export function formatAllowFromLowercase(params: { .map((entry) => entry.toLowerCase()); } +export function isNormalizedSenderAllowed(params: { + senderId: string | number; + allowFrom: Array; + stripPrefixRe?: RegExp; +}): boolean { + const normalizedAllow = formatAllowFromLowercase({ + allowFrom: params.allowFrom, + stripPrefixRe: params.stripPrefixRe, + }); + if (normalizedAllow.length === 0) { + return false; + } + if (normalizedAllow.includes("*")) { + return true; + } + const sender = String(params.senderId).trim().toLowerCase(); + return normalizedAllow.includes(sender); +} + type ParsedChatAllowTarget = | { kind: "chat_id"; chatId: number } | { kind: "chat_guid"; chatGuid: string } diff --git a/src/plugin-sdk/group-access.test.ts b/src/plugin-sdk/group-access.test.ts new file mode 100644 index 000000000000..77eaf7a0fa26 --- /dev/null +++ b/src/plugin-sdk/group-access.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { evaluateSenderGroupAccess } from "./group-access.js"; + +describe("evaluateSenderGroupAccess", () => { + it("defaults missing provider config to allowlist", () => { + const decision = evaluateSenderGroupAccess({ + providerConfigPresent: false, + configuredGroupPolicy: undefined, + defaultGroupPolicy: "open", + groupAllowFrom: ["123"], + senderId: "123", + isSenderAllowed: () => true, + }); + + expect(decision).toEqual({ + allowed: true, + groupPolicy: "allowlist", + providerMissingFallbackApplied: true, + reason: "allowed", + }); + }); + + it("blocks disabled policy", () => { + const decision = evaluateSenderGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "disabled", + defaultGroupPolicy: "open", + groupAllowFrom: ["123"], + senderId: "123", + isSenderAllowed: () => true, + }); + + expect(decision).toMatchObject({ allowed: false, reason: "disabled", groupPolicy: "disabled" }); + }); + + it("blocks allowlist with empty list", () => { + const decision = evaluateSenderGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: [], + senderId: "123", + isSenderAllowed: () => true, + }); + + expect(decision).toMatchObject({ + allowed: false, + reason: "empty_allowlist", + groupPolicy: "allowlist", + }); + }); + + it("blocks sender not allowlisted", () => { + const decision = evaluateSenderGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["123"], + senderId: "999", + isSenderAllowed: () => false, + }); + + expect(decision).toMatchObject({ + allowed: false, + reason: "sender_not_allowlisted", + groupPolicy: "allowlist", + }); + }); +}); diff --git a/src/plugin-sdk/group-access.ts b/src/plugin-sdk/group-access.ts new file mode 100644 index 000000000000..872b7dc8d76b --- /dev/null +++ b/src/plugin-sdk/group-access.ts @@ -0,0 +1,64 @@ +import { resolveOpenProviderRuntimeGroupPolicy } from "../config/runtime-group-policy.js"; +import type { GroupPolicy } from "../config/types.base.js"; + +export type SenderGroupAccessReason = + | "allowed" + | "disabled" + | "empty_allowlist" + | "sender_not_allowlisted"; + +export type SenderGroupAccessDecision = { + allowed: boolean; + groupPolicy: GroupPolicy; + providerMissingFallbackApplied: boolean; + reason: SenderGroupAccessReason; +}; + +export function evaluateSenderGroupAccess(params: { + providerConfigPresent: boolean; + configuredGroupPolicy?: GroupPolicy; + defaultGroupPolicy?: GroupPolicy; + groupAllowFrom: string[]; + senderId: string; + isSenderAllowed: (senderId: string, allowFrom: string[]) => boolean; +}): SenderGroupAccessDecision { + const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.configuredGroupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + }); + + if (groupPolicy === "disabled") { + return { + allowed: false, + groupPolicy, + providerMissingFallbackApplied, + reason: "disabled", + }; + } + if (groupPolicy === "allowlist") { + if (params.groupAllowFrom.length === 0) { + return { + allowed: false, + groupPolicy, + providerMissingFallbackApplied, + reason: "empty_allowlist", + }; + } + if (!params.isSenderAllowed(params.senderId, params.groupAllowFrom)) { + return { + allowed: false, + groupPolicy, + providerMissingFallbackApplied, + reason: "sender_not_allowlisted", + }; + } + } + + return { + allowed: true, + groupPolicy, + providerMissingFallbackApplied, + reason: "allowed", + }; +} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 9c54fe175f69..7faa2341dc03 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -181,7 +181,16 @@ export { normalizeAccountId, resolveThreadSessionKeys, } from "../routing/session-key.js"; -export { formatAllowFromLowercase, isAllowedParsedChatSender } from "./allow-from.js"; +export { + formatAllowFromLowercase, + isAllowedParsedChatSender, + isNormalizedSenderAllowed, +} from "./allow-from.js"; +export { + evaluateSenderGroupAccess, + type SenderGroupAccessDecision, + type SenderGroupAccessReason, +} from "./group-access.js"; export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { handleSlackMessageAction } from "./slack-message-actions.js"; export { extractToolSend } from "./tool-send.js"; From 5a64f6d7669201553f5a5ecac7427b44a1fb1cf6 Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Tue, 24 Feb 2026 12:14:35 -0700 Subject: [PATCH 270/408] Gateway/Security: protect /api/channels plugin root --- src/gateway/server-http.ts | 2 +- src/gateway/server.plugin-http-auth.test.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index e67737b5b76c..72a81a769ad6 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -491,7 +491,7 @@ export function createGatewayHttpServer(opts: { // Channel HTTP endpoints are gateway-auth protected by default. // Non-channel plugin routes remain plugin-owned and must enforce // their own auth when exposing sensitive functionality. - if (requestPath.startsWith("/api/channels/")) { + if (requestPath === "/api/channels" || requestPath.startsWith("/api/channels/")) { const token = getBearerToken(req); const authResult = await authorizeHttpGatewayConnect({ auth: resolvedAuth, diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index f932e1e2a358..25568d4803e4 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -142,6 +142,12 @@ describe("gateway plugin HTTP auth boundary", () => { run: async () => { const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/api/channels") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "channel-root" })); + return true; + } if (pathname === "/api/channels/nostr/default/profile") { res.statusCode = 200; res.setHeader("Content-Type", "application/json; charset=utf-8"); @@ -179,6 +185,16 @@ describe("gateway plugin HTTP auth boundary", () => { expect(unauthenticated.getBody()).toContain("Unauthorized"); expect(handlePluginRequest).not.toHaveBeenCalled(); + const unauthenticatedRoot = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/api/channels" }), + unauthenticatedRoot.res, + ); + expect(unauthenticatedRoot.res.statusCode).toBe(401); + expect(unauthenticatedRoot.getBody()).toContain("Unauthorized"); + expect(handlePluginRequest).not.toHaveBeenCalled(); + const authenticated = createResponse(); await dispatchRequest( server, From 9514201fb9b51de5d0b23151110d0ff5d9c8bd67 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:43:37 +0000 Subject: [PATCH 271/408] fix(telegram): block unauthorized DM media downloads --- CHANGELOG.md | 1 + src/telegram/bot-handlers.ts | 34 +++++ src/telegram/bot-message-context.ts | 113 +++------------- src/telegram/bot-native-commands.ts | 1 + src/telegram/bot.create-telegram-bot.test.ts | 127 ++++++++++++++++++ ...s-media-file-path-no-file-download.test.ts | 2 +- src/telegram/bot.ts | 1 + src/telegram/dm-access.ts | 109 +++++++++++++++ 8 files changed, 295 insertions(+), 93 deletions(-) create mode 100644 src/telegram/dm-access.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 354b008b5aa4..ffbb3531c895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. - Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. This ships in the next npm release. Thanks @GCXWLP for reporting. - Zalo/Group policy: enforce sender authorization for group messages with `groupPolicy` + `groupAllowFrom` (fallback to `allowFrom`), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Telegram: enforce DM authorization before media download/write (including media groups) and move telegram inbound activity tracking after DM authorization, preventing unauthorized sender-triggered inbound media disk writes. This ships in the next npm release. Thanks @v8hid for reporting. - Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so `dmPolicy: "allowlist"` with empty `allowedUserIds` rejects all senders instead of allowing unauthorized dispatch. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 5d3cfc30b4a7..d0817c1f9da3 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -45,6 +45,7 @@ import { resolveTelegramGroupAllowFromContext, } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; +import { enforceTelegramDmAccess } from "./dm-access.js"; import { evaluateTelegramGroupBaseAccess, evaluateTelegramGroupPolicyAccess, @@ -79,6 +80,7 @@ export const registerTelegramHandlers = ({ runtime, mediaMaxBytes, telegramCfg, + allowFrom, groupAllowFrom, resolveGroupPolicy, resolveTelegramGroupConfig, @@ -1182,6 +1184,38 @@ export const registerTelegramHandlers = ({ return; } + const hasInboundMedia = + Boolean(event.msg.media_group_id) || + (Array.isArray(event.msg.photo) && event.msg.photo.length > 0) || + Boolean( + event.msg.video ?? + event.msg.video_note ?? + event.msg.document ?? + event.msg.audio ?? + event.msg.voice ?? + event.msg.sticker, + ); + if (!event.isGroup && hasInboundMedia) { + const effectiveDmAllow = normalizeAllowFromWithStore({ + allowFrom, + storeAllowFrom, + dmPolicy: telegramCfg.dmPolicy ?? "pairing", + }); + const dmAuthorized = await enforceTelegramDmAccess({ + isGroup: event.isGroup, + dmPolicy: telegramCfg.dmPolicy ?? "pairing", + msg: event.msg, + chatId: event.chatId, + effectiveDmAllow, + accountId, + bot, + logger, + }); + if (!dmAuthorized) { + return; + } + } + await processInboundMessage({ ctx: event.ctx, msg: event.msg, diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index b3fa5b9f60f1..3ea805c944d5 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -33,17 +33,10 @@ import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js"; import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; -import { buildPairingReply } from "../pairing/pairing-messages.js"; -import { upsertChannelPairingRequest } from "../pairing/pairing-store.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; -import { - firstDefined, - isSenderAllowed, - normalizeAllowFromWithStore, - resolveSenderAllowMatch, -} from "./bot-access.js"; +import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js"; import { buildGroupLabel, buildSenderLabel, @@ -61,6 +54,7 @@ import { resolveTelegramThreadSpec, } from "./bot/helpers.js"; import type { StickerMetadata, TelegramContext } from "./bot/types.js"; +import { enforceTelegramDmAccess } from "./dm-access.js"; import { evaluateTelegramGroupBaseAccess } from "./group-access.js"; import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js"; import { @@ -159,11 +153,6 @@ export const buildTelegramMessageContext = async ({ resolveTelegramGroupConfig, }: BuildTelegramMessageContextParams) => { const msg = primaryCtx.message; - recordChannelActivity({ - channel: "telegram", - accountId: account.accountId, - direction: "inbound", - }); const chatId = msg.chat.id; const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; @@ -268,87 +257,27 @@ export const buildTelegramMessageContext = async ({ } }; - // DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled" - if (!isGroup) { - if (dmPolicy === "disabled") { - return null; - } - - if (dmPolicy !== "open") { - const senderUsername = msg.from?.username ?? ""; - const senderUserId = msg.from?.id != null ? String(msg.from.id) : null; - const candidate = senderUserId ?? String(chatId); - const allowMatch = resolveSenderAllowMatch({ - allow: effectiveDmAllow, - senderId: candidate, - senderUsername, - }); - const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${ - allowMatch.matchSource ?? "none" - }`; - const allowed = - effectiveDmAllow.hasWildcard || (effectiveDmAllow.hasEntries && allowMatch.allowed); - if (!allowed) { - if (dmPolicy === "pairing") { - try { - const from = msg.from as - | { - first_name?: string; - last_name?: string; - username?: string; - id?: number; - } - | undefined; - const telegramUserId = from?.id ? String(from.id) : candidate; - const { code, created } = await upsertChannelPairingRequest({ - channel: "telegram", - id: telegramUserId, - accountId: account.accountId, - meta: { - username: from?.username, - firstName: from?.first_name, - lastName: from?.last_name, - }, - }); - if (created) { - logger.info( - { - chatId: String(chatId), - senderUserId: senderUserId ?? undefined, - username: from?.username, - firstName: from?.first_name, - lastName: from?.last_name, - matchKey: allowMatch.matchKey ?? "none", - matchSource: allowMatch.matchSource ?? "none", - }, - "telegram pairing request", - ); - await withTelegramApiErrorLogging({ - operation: "sendMessage", - fn: () => - bot.api.sendMessage( - chatId, - buildPairingReply({ - channel: "telegram", - idLine: `Your Telegram user id: ${telegramUserId}`, - code, - }), - ), - }); - } - } catch (err) { - logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`); - } - } else { - logVerbose( - `Blocked unauthorized telegram sender ${candidate} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, - ); - } - return null; - } - } + if ( + !(await enforceTelegramDmAccess({ + isGroup, + dmPolicy, + msg, + chatId, + effectiveDmAllow, + accountId: account.accountId, + bot, + logger, + })) + ) { + return null; } + recordChannelActivity({ + channel: "telegram", + accountId: account.accountId, + direction: "inbound", + }); + const botUsername = primaryCtx.me?.username?.toLowerCase(); const allowForCommands = isGroup ? effectiveGroupAllow : effectiveDmAllow; const senderAllowedForCommands = isSenderAllowed({ diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index adf413fbfb9a..88316cbeb82b 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -91,6 +91,7 @@ export type RegisterTelegramHandlerParams = { opts: TelegramBotOptions; runtime: RuntimeEnv; telegramCfg: TelegramAccountConfig; + allowFrom?: Array; groupAllowFrom?: Array; resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy; resolveTelegramGroupConfig: ( diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index 816cf224dd3c..5bf422506210 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -329,6 +329,133 @@ describe("createTelegramBot", () => { } } }); + it("blocks unauthorized DM media before download and sends pairing reply", async () => { + loadConfig.mockReturnValue({ + channels: { telegram: { dmPolicy: "pairing" } }, + }); + readChannelAllowFromStore.mockResolvedValue([]); + upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true }); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }), + ); + const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" })); + + try { + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + message_id: 410, + date: 1736380800, + photo: [{ file_id: "p1" }], + from: { id: 999, username: "random" }, + }, + me: { username: "openclaw_bot" }, + getFile: getFileSpy, + }); + + expect(getFileSpy).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:"); + expect(replySpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + it("blocks DM media downloads completely when dmPolicy is disabled", async () => { + loadConfig.mockReturnValue({ + channels: { telegram: { dmPolicy: "disabled" } }, + }); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }), + ); + const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" })); + + try { + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + message_id: 411, + date: 1736380800, + photo: [{ file_id: "p1" }], + from: { id: 999, username: "random" }, + }, + me: { username: "openclaw_bot" }, + getFile: getFileSpy, + }); + + expect(getFileSpy).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).not.toHaveBeenCalled(); + expect(replySpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + it("blocks unauthorized DM media groups before any photo download", async () => { + loadConfig.mockReturnValue({ + channels: { telegram: { dmPolicy: "pairing" } }, + }); + readChannelAllowFromStore.mockResolvedValue([]); + upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true }); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }), + ); + const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" })); + + try { + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + message_id: 412, + media_group_id: "dm-album-1", + date: 1736380800, + photo: [{ file_id: "p1" }], + from: { id: 999, username: "random" }, + }, + me: { username: "openclaw_bot" }, + getFile: getFileSpy, + }); + + expect(getFileSpy).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:"); + expect(replySpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); it("triggers typing cue via onReplyStart", async () => { createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts index 8f34fcdeb2bd..8b2089a6984b 100644 --- a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts +++ b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts @@ -370,7 +370,7 @@ describe("telegram media groups", () => { () => { expect(replySpy).toHaveBeenCalledTimes(scenario.expectedReplyCount); }, - { timeout: MEDIA_GROUP_FLUSH_MS * 2, interval: 2 }, + { timeout: MEDIA_GROUP_FLUSH_MS * 4, interval: 2 }, ); expect(runtimeError).not.toHaveBeenCalled(); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 438ed1c9bb80..409815fa3ae5 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -398,6 +398,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { runtime, mediaMaxBytes, telegramCfg, + allowFrom, groupAllowFrom, resolveGroupPolicy, resolveTelegramGroupConfig, diff --git a/src/telegram/dm-access.ts b/src/telegram/dm-access.ts new file mode 100644 index 000000000000..9d7e1d463e70 --- /dev/null +++ b/src/telegram/dm-access.ts @@ -0,0 +1,109 @@ +import type { Message } from "@grammyjs/types"; +import type { Bot } from "grammy"; +import type { DmPolicy } from "../config/types.js"; +import { logVerbose } from "../globals.js"; +import { buildPairingReply } from "../pairing/pairing-messages.js"; +import { upsertChannelPairingRequest } from "../pairing/pairing-store.js"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { resolveSenderAllowMatch, type NormalizedAllowFrom } from "./bot-access.js"; + +type TelegramDmAccessLogger = { + info: (obj: Record, msg: string) => void; +}; + +export async function enforceTelegramDmAccess(params: { + isGroup: boolean; + dmPolicy: DmPolicy; + msg: Message; + chatId: number; + effectiveDmAllow: NormalizedAllowFrom; + accountId: string; + bot: Bot; + logger: TelegramDmAccessLogger; +}): Promise { + const { isGroup, dmPolicy, msg, chatId, effectiveDmAllow, accountId, bot, logger } = params; + if (isGroup) { + return true; + } + if (dmPolicy === "disabled") { + return false; + } + if (dmPolicy === "open") { + return true; + } + + const senderUsername = msg.from?.username ?? ""; + const senderUserId = msg.from?.id != null ? String(msg.from.id) : null; + const candidate = senderUserId ?? String(chatId); + const allowMatch = resolveSenderAllowMatch({ + allow: effectiveDmAllow, + senderId: candidate, + senderUsername, + }); + const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${ + allowMatch.matchSource ?? "none" + }`; + const allowed = + effectiveDmAllow.hasWildcard || (effectiveDmAllow.hasEntries && allowMatch.allowed); + if (allowed) { + return true; + } + + if (dmPolicy === "pairing") { + try { + const from = msg.from as + | { + first_name?: string; + last_name?: string; + username?: string; + id?: number; + } + | undefined; + const telegramUserId = from?.id ? String(from.id) : candidate; + const { code, created } = await upsertChannelPairingRequest({ + channel: "telegram", + id: telegramUserId, + accountId, + meta: { + username: from?.username, + firstName: from?.first_name, + lastName: from?.last_name, + }, + }); + if (created) { + logger.info( + { + chatId: String(chatId), + senderUserId: senderUserId ?? undefined, + username: from?.username, + firstName: from?.first_name, + lastName: from?.last_name, + matchKey: allowMatch.matchKey ?? "none", + matchSource: allowMatch.matchSource ?? "none", + }, + "telegram pairing request", + ); + await withTelegramApiErrorLogging({ + operation: "sendMessage", + fn: () => + bot.api.sendMessage( + chatId, + buildPairingReply({ + channel: "telegram", + idLine: `Your Telegram user id: ${telegramUserId}`, + code, + }), + ), + }); + } + } catch (err) { + logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`); + } + return false; + } + + logVerbose( + `Blocked unauthorized telegram sender ${candidate} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, + ); + return false; +} From 48b052322bd254cdff76d0cea515f8670b7f543e Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Tue, 24 Feb 2026 12:09:42 -0700 Subject: [PATCH 272/408] Security: sanitize inherited host exec env --- src/agents/bash-tools.exec-runtime.ts | 17 +++++++++++++++++ src/agents/bash-tools.exec.path.test.ts | 23 +++++++++++++++++++++++ src/agents/bash-tools.exec.ts | 4 +++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 2a6db05669c3..05973993cffc 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -29,6 +29,23 @@ import { import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js"; import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js"; +// Sanitize inherited host env before merge so dangerous variables from process.env +// are not propagated into non-sandboxed executions. +export function sanitizeHostBaseEnv(env: Record): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(env)) { + const upperKey = key.toUpperCase(); + if (upperKey === "PATH") { + sanitized[key] = value; + continue; + } + if (isDangerousHostEnvVarName(upperKey)) { + continue; + } + sanitized[key] = value; + } + return sanitized; +} // Centralized sanitization helper. // Throws an error if dangerous variables or PATH modifications are detected on the host. export function validateHostEnv(env: Record): void { diff --git a/src/agents/bash-tools.exec.path.test.ts b/src/agents/bash-tools.exec.path.test.ts index 5481ec9668d9..041ee86723ee 100644 --- a/src/agents/bash-tools.exec.path.test.ts +++ b/src/agents/bash-tools.exec.path.test.ts @@ -166,6 +166,29 @@ describe("exec host env validation", () => { ).rejects.toThrow(/Security Violation: Environment variable 'LD_DEBUG' is forbidden/); }); + it("strips dangerous inherited env vars from host execution", async () => { + if (isWin) { + return; + } + const original = process.env.SSLKEYLOGFILE; + process.env.SSLKEYLOGFILE = "/tmp/openclaw-ssl-keys.log"; + try { + const { createExecTool } = await import("./bash-tools.exec.js"); + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); + const result = await tool.execute("call1", { + command: "printf '%s' \"${SSLKEYLOGFILE:-}\"", + }); + const output = normalizeText(result.content.find((c) => c.type === "text")?.text); + expect(output).not.toContain("/tmp/openclaw-ssl-keys.log"); + } finally { + if (original === undefined) { + delete process.env.SSLKEYLOGFILE; + } else { + process.env.SSLKEYLOGFILE = original; + } + } + }); + it("defaults to sandbox when sandbox runtime is unavailable", async () => { const tool = createExecTool({ security: "full", ask: "off" }); diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index a9d230c24b62..fac68eb823f4 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -25,6 +25,7 @@ import { renderExecHostLabel, resolveApprovalRunningNoticeMs, runExecProcess, + sanitizeHostBaseEnv, execSchema, validateHostEnv, } from "./bash-tools.exec-runtime.js"; @@ -359,7 +360,8 @@ export function createExecTool( workdir = resolveWorkdir(rawWorkdir, warnings); } - const baseEnv = coerceEnv(process.env); + const inheritedBaseEnv = coerceEnv(process.env); + const baseEnv = host === "sandbox" ? inheritedBaseEnv : sanitizeHostBaseEnv(inheritedBaseEnv); // Logic: Sandbox gets raw env. Host (gateway/node) must pass validation. // We validate BEFORE merging to prevent any dangerous vars from entering the stream. From 43a3ff3bebf8ae0754411e5b66f26c3f09fbb317 Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Tue, 24 Feb 2026 12:11:16 -0700 Subject: [PATCH 273/408] Changelog: add entry for exec env sanitization --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffbb3531c895..b74f1d444c8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -598,6 +598,7 @@ Docs: https://docs.openclaw.ai - Security/Media: harden local media ingestion against TOCTOU/symlink swap attacks by pinning reads to a single file descriptor with symlink rejection and inode/device verification in `saveMediaSource`. Thanks @dorjoos for reporting. - Security/Lobster (Windows): for the next npm release, remove shell-based fallback when launching Lobster wrappers (`.cmd`/`.bat`) and switch to explicit argv execution with wrapper entrypoint resolution, preventing command injection while preserving Windows wrapper compatibility. Thanks @allsmog for reporting. - Security/Exec: require `tools.exec.safeBins` binaries to resolve from trusted bin directories (system defaults plus gateway startup `PATH`) so PATH-hijacked trojan binaries cannot bypass allowlist checks. Thanks @jackhax for reporting. +- Security/Exec: sanitize inherited host execution environment before merge and strip dangerous keys (`LD_*`, `DYLD_*`, `SSLKEYLOGFILE`, and related injection vectors) from non-sandboxed exec runs. (#9792) - Security/Exec: remove file-existence oracle behavior from `tools.exec.safeBins` by using deterministic argv-only stdin-safe validation and blocking file-oriented flags (for example `sort -o`, `jq -f`, `grep -f`) so allow/deny results no longer disclose host file presence. Thanks @nedlir for reporting. - Security/Browser: route browser URL navigation through one SSRF-guarded validation path for tab-open/CDP-target/Playwright navigation flows and block private/metadata destinations by default (configurable via `browser.ssrfPolicy`). Thanks @dorjoos for reporting. - Security/Exec: for the next npm release, harden safe-bin stdin-only enforcement by blocking output/recursive flags (`sort -o/--output`, grep recursion) and tightening default safe bins to remove `sort`/`grep`, preventing safe-bin allowlist bypass for file writes/recursive reads. Thanks @nedlir for reporting. From 9924f7c84ee2e6c953d00b8c86f38e92360bd02e Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Tue, 24 Feb 2026 12:33:35 -0700 Subject: [PATCH 274/408] fix(security): classify hook sessions case-insensitively --- src/security/external-content.test.ts | 12 ++++++++++++ src/security/external-content.ts | 14 ++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/security/external-content.test.ts b/src/security/external-content.test.ts index 3e22bb34c4a4..6bbe5e65d866 100644 --- a/src/security/external-content.test.ts +++ b/src/security/external-content.test.ts @@ -246,6 +246,12 @@ describe("external-content security", () => { expect(isExternalHookSession("hook:custom:456")).toBe(true); }); + it("identifies mixed-case hook prefixes", () => { + expect(isExternalHookSession("HOOK:gmail:msg-123")).toBe(true); + expect(isExternalHookSession("Hook:custom:456")).toBe(true); + expect(isExternalHookSession(" HOOK:webhook:123 ")).toBe(true); + }); + it("rejects non-hook sessions", () => { expect(isExternalHookSession("cron:daily-task")).toBe(false); expect(isExternalHookSession("agent:main")).toBe(false); @@ -266,6 +272,12 @@ describe("external-content security", () => { expect(getHookType("hook:custom:456")).toBe("webhook"); }); + it("returns hook type for mixed-case hook prefixes", () => { + expect(getHookType("HOOK:gmail:msg-123")).toBe("email"); + expect(getHookType(" HOOK:webhook:123 ")).toBe("webhook"); + expect(getHookType("Hook:custom:456")).toBe("webhook"); + }); + it("returns unknown for non-hook sessions", () => { expect(getHookType("cron:daily")).toBe("unknown"); }); diff --git a/src/security/external-content.ts b/src/security/external-content.ts index 49629db9aef0..e1fd9335d7da 100644 --- a/src/security/external-content.ts +++ b/src/security/external-content.ts @@ -286,10 +286,11 @@ export function buildSafeExternalPrompt(params: { * Checks if a session key indicates an external hook source. */ export function isExternalHookSession(sessionKey: string): boolean { + const normalized = sessionKey.trim().toLowerCase(); return ( - sessionKey.startsWith("hook:gmail:") || - sessionKey.startsWith("hook:webhook:") || - sessionKey.startsWith("hook:") // Generic hook prefix + normalized.startsWith("hook:gmail:") || + normalized.startsWith("hook:webhook:") || + normalized.startsWith("hook:") // Generic hook prefix ); } @@ -297,13 +298,14 @@ export function isExternalHookSession(sessionKey: string): boolean { * Extracts the hook type from a session key. */ export function getHookType(sessionKey: string): ExternalContentSource { - if (sessionKey.startsWith("hook:gmail:")) { + const normalized = sessionKey.trim().toLowerCase(); + if (normalized.startsWith("hook:gmail:")) { return "email"; } - if (sessionKey.startsWith("hook:webhook:")) { + if (normalized.startsWith("hook:webhook:")) { return "webhook"; } - if (sessionKey.startsWith("hook:")) { + if (normalized.startsWith("hook:")) { return "webhook"; } return "unknown"; From 316fad13aad3a55cd080c1c1fa77f3e919cb0959 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:48:31 +0000 Subject: [PATCH 275/408] refactor(outbound): unify attachment hydration flow --- src/infra/outbound/message-action-params.ts | 34 +++---- .../outbound/message-action-runner.test.ts | 88 ++++++++++--------- src/infra/outbound/message-action-runner.ts | 15 +--- 3 files changed, 61 insertions(+), 76 deletions(-) diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index b24146cb97cd..a73912edc6e4 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -279,9 +279,10 @@ async function hydrateAttachmentPayload(params: { export async function normalizeSandboxMediaParams(params: { args: Record; - sandboxRoot?: string; + mediaPolicy: AttachmentMediaPolicy; }): Promise { - const sandboxRoot = params.sandboxRoot?.trim(); + const sandboxRoot = + params.mediaPolicy.mode === "sandbox" ? params.mediaPolicy.sandboxRoot.trim() : undefined; const mediaKeys: Array<"media" | "path" | "filePath"> = ["media", "path", "filePath"]; for (const key of mediaKeys) { const raw = readStringParam(params.args, key, { trim: false }); @@ -362,22 +363,7 @@ async function hydrateAttachmentActionPayload(params: { }); } -export async function hydrateSetGroupIconParams(params: { - cfg: OpenClawConfig; - channel: ChannelId; - accountId?: string | null; - args: Record; - action: ChannelMessageActionName; - dryRun?: boolean; - mediaPolicy: AttachmentMediaPolicy; -}): Promise { - if (params.action !== "setGroupIcon") { - return; - } - await hydrateAttachmentActionPayload(params); -} - -export async function hydrateSendAttachmentParams(params: { +export async function hydrateAttachmentParamsForAction(params: { cfg: OpenClawConfig; channel: ChannelId; accountId?: string | null; @@ -386,10 +372,18 @@ export async function hydrateSendAttachmentParams(params: { dryRun?: boolean; mediaPolicy: AttachmentMediaPolicy; }): Promise { - if (params.action !== "sendAttachment") { + if (params.action !== "sendAttachment" && params.action !== "setGroupIcon") { return; } - await hydrateAttachmentActionPayload({ ...params, allowMessageCaptionFallback: true }); + await hydrateAttachmentActionPayload({ + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + args: params.args, + dryRun: params.dryRun, + mediaPolicy: params.mediaPolicy, + allowMessageCaptionFallback: params.action === "sendAttachment", + }); } export function parseButtonsParam(params: Record): void { diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index 127a38380310..ed1fbf47eb3b 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -490,6 +490,40 @@ describe("runMessageAction sendAttachment hydration", () => { vi.mocked(loadWebMedia).mockImplementation(actual.loadWebMedia); } + async function expectRejectsLocalAbsolutePathWithoutSandbox(params: { + action: "sendAttachment" | "setGroupIcon"; + target: string; + message?: string; + tempPrefix: string; + }) { + await restoreRealMediaLoader(); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), params.tempPrefix)); + try { + const outsidePath = path.join(tempDir, "secret.txt"); + await fs.writeFile(outsidePath, "secret", "utf8"); + + const actionParams: Record = { + channel: "bluebubbles", + target: params.target, + media: outsidePath, + }; + if (params.message) { + actionParams.message = params.message; + } + + await expect( + runMessageAction({ + cfg, + action: params.action, + params: actionParams, + }), + ).rejects.toThrow(/allowed directory|path-not-allowed/i); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + } + it("hydrates buffer and filename from media for sendAttachment", async () => { const result = await runMessageAction({ cfg, @@ -548,52 +582,20 @@ describe("runMessageAction sendAttachment hydration", () => { }); it("rejects local absolute path for sendAttachment when sandboxRoot is missing", async () => { - await restoreRealMediaLoader(); - - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-attachment-")); - try { - const outsidePath = path.join(tempDir, "secret.txt"); - await fs.writeFile(outsidePath, "secret", "utf8"); - - await expect( - runMessageAction({ - cfg, - action: "sendAttachment", - params: { - channel: "bluebubbles", - target: "+15551234567", - media: outsidePath, - message: "caption", - }, - }), - ).rejects.toThrow(/allowed directory|path-not-allowed/i); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + await expectRejectsLocalAbsolutePathWithoutSandbox({ + action: "sendAttachment", + target: "+15551234567", + message: "caption", + tempPrefix: "msg-attachment-", + }); }); it("rejects local absolute path for setGroupIcon when sandboxRoot is missing", async () => { - await restoreRealMediaLoader(); - - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-group-icon-")); - try { - const outsidePath = path.join(tempDir, "secret.txt"); - await fs.writeFile(outsidePath, "secret", "utf8"); - - await expect( - runMessageAction({ - cfg, - action: "setGroupIcon", - params: { - channel: "bluebubbles", - target: "group:123", - media: outsidePath, - }, - }), - ).rejects.toThrow(/allowed directory|path-not-allowed/i); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + await expectRejectsLocalAbsolutePathWithoutSandbox({ + action: "setGroupIcon", + target: "group:123", + tempPrefix: "msg-group-icon-", + }); }); }); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 68a75f0c0a36..57032e27de85 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -28,8 +28,7 @@ import { import { applyTargetToParams } from "./channel-target.js"; import type { OutboundSendDeps } from "./deliver.js"; import { - hydrateSendAttachmentParams, - hydrateSetGroupIconParams, + hydrateAttachmentParamsForAction, normalizeSandboxMediaList, normalizeSandboxMediaParams, parseButtonsParam, @@ -767,20 +766,10 @@ export async function runMessageAction( await normalizeSandboxMediaParams({ args: params, - sandboxRoot: mediaPolicy.mode === "sandbox" ? mediaPolicy.sandboxRoot : undefined, - }); - - await hydrateSendAttachmentParams({ - cfg, - channel, - accountId, - args: params, - action, - dryRun, mediaPolicy, }); - await hydrateSetGroupIconParams({ + await hydrateAttachmentParamsForAction({ cfg, channel, accountId, From 36d1e1dcffca8dc5fc9a693841d62af267bd1faa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:49:05 +0000 Subject: [PATCH 276/408] refactor(telegram): simplify DM media auth precheck flow --- src/telegram/bot-handlers.ts | 36 ++++++++++++------------ src/telegram/dm-access.ts | 54 +++++++++++++++++++++--------------- 2 files changed, 49 insertions(+), 41 deletions(-) diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index d0817c1f9da3..e4d42cd889e5 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -72,6 +72,14 @@ function isRecoverableMediaGroupError(err: unknown): boolean { return err instanceof MediaFetchError || isMediaSizeLimitError(err); } +function hasInboundMedia(msg: Message): boolean { + return ( + Boolean(msg.media_group_id) || + (Array.isArray(msg.photo) && msg.photo.length > 0) || + Boolean(msg.video ?? msg.video_note ?? msg.document ?? msg.audio ?? msg.voice ?? msg.sticker) + ); +} + export const registerTelegramHandlers = ({ cfg, accountId, @@ -1143,11 +1151,12 @@ export const registerTelegramHandlers = ({ if (shouldSkipUpdate(event.ctxForDedupe)) { return; } + const dmPolicy = telegramCfg.dmPolicy ?? "pairing"; const groupAllowContext = await resolveTelegramGroupAllowFromContext({ chatId: event.chatId, accountId, - dmPolicy: telegramCfg.dmPolicy ?? "pairing", + dmPolicy, isForum: event.isForum, messageThreadId: event.messageThreadId, groupAllowFrom, @@ -1161,6 +1170,11 @@ export const registerTelegramHandlers = ({ effectiveGroupAllow, hasGroupAllowOverride, } = groupAllowContext; + const effectiveDmAllow = normalizeAllowFromWithStore({ + allowFrom, + storeAllowFrom, + dmPolicy, + }); if (event.requireConfiguredGroup && (!groupConfig || groupConfig.enabled === false)) { logVerbose(`Blocked telegram channel ${event.chatId} (channel disabled)`); @@ -1184,26 +1198,10 @@ export const registerTelegramHandlers = ({ return; } - const hasInboundMedia = - Boolean(event.msg.media_group_id) || - (Array.isArray(event.msg.photo) && event.msg.photo.length > 0) || - Boolean( - event.msg.video ?? - event.msg.video_note ?? - event.msg.document ?? - event.msg.audio ?? - event.msg.voice ?? - event.msg.sticker, - ); - if (!event.isGroup && hasInboundMedia) { - const effectiveDmAllow = normalizeAllowFromWithStore({ - allowFrom, - storeAllowFrom, - dmPolicy: telegramCfg.dmPolicy ?? "pairing", - }); + if (!event.isGroup && hasInboundMedia(event.msg)) { const dmAuthorized = await enforceTelegramDmAccess({ isGroup: event.isGroup, - dmPolicy: telegramCfg.dmPolicy ?? "pairing", + dmPolicy, msg: event.msg, chatId: event.chatId, effectiveDmAllow, diff --git a/src/telegram/dm-access.ts b/src/telegram/dm-access.ts index 9d7e1d463e70..1c68dd43d69d 100644 --- a/src/telegram/dm-access.ts +++ b/src/telegram/dm-access.ts @@ -11,6 +11,26 @@ type TelegramDmAccessLogger = { info: (obj: Record, msg: string) => void; }; +type TelegramSenderIdentity = { + username: string; + userId: string | null; + candidateId: string; + firstName?: string; + lastName?: string; +}; + +function resolveTelegramSenderIdentity(msg: Message, chatId: number): TelegramSenderIdentity { + const from = msg.from; + const userId = from?.id != null ? String(from.id) : null; + return { + username: from?.username ?? "", + userId, + candidateId: userId ?? String(chatId), + firstName: from?.first_name, + lastName: from?.last_name, + }; +} + export async function enforceTelegramDmAccess(params: { isGroup: boolean; dmPolicy: DmPolicy; @@ -32,13 +52,11 @@ export async function enforceTelegramDmAccess(params: { return true; } - const senderUsername = msg.from?.username ?? ""; - const senderUserId = msg.from?.id != null ? String(msg.from.id) : null; - const candidate = senderUserId ?? String(chatId); + const sender = resolveTelegramSenderIdentity(msg, chatId); const allowMatch = resolveSenderAllowMatch({ allow: effectiveDmAllow, - senderId: candidate, - senderUsername, + senderId: sender.candidateId, + senderUsername: sender.username, }); const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${ allowMatch.matchSource ?? "none" @@ -51,33 +69,25 @@ export async function enforceTelegramDmAccess(params: { if (dmPolicy === "pairing") { try { - const from = msg.from as - | { - first_name?: string; - last_name?: string; - username?: string; - id?: number; - } - | undefined; - const telegramUserId = from?.id ? String(from.id) : candidate; + const telegramUserId = sender.userId ?? sender.candidateId; const { code, created } = await upsertChannelPairingRequest({ channel: "telegram", id: telegramUserId, accountId, meta: { - username: from?.username, - firstName: from?.first_name, - lastName: from?.last_name, + username: sender.username || undefined, + firstName: sender.firstName, + lastName: sender.lastName, }, }); if (created) { logger.info( { chatId: String(chatId), - senderUserId: senderUserId ?? undefined, - username: from?.username, - firstName: from?.first_name, - lastName: from?.last_name, + senderUserId: sender.userId ?? undefined, + username: sender.username || undefined, + firstName: sender.firstName, + lastName: sender.lastName, matchKey: allowMatch.matchKey ?? "none", matchSource: allowMatch.matchSource ?? "none", }, @@ -103,7 +113,7 @@ export async function enforceTelegramDmAccess(params: { } logVerbose( - `Blocked unauthorized telegram sender ${candidate} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, + `Blocked unauthorized telegram sender ${sender.candidateId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, ); return false; } From 53f9b7d4e74a109dee007edf93593070c04bd086 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:48:49 +0000 Subject: [PATCH 277/408] fix(automation): harden announce delivery + cron coding profile (#25813 #25821 #25822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Shawn Co-authored-by: 不做了睡大觉 Co-authored-by: Marcus Widing --- CHANGELOG.md | 1 + extensions/feishu/src/media.test.ts | 4 +- src/agents/subagent-announce.format.test.ts | 96 +++++++++++ src/agents/subagent-announce.ts | 161 +++++++++++++++--- src/agents/tool-catalog.ts | 2 +- src/agents/tool-policy.test.ts | 2 + .../tools-invoke-http.cron-regression.test.ts | 19 +++ 7 files changed, 255 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b74f1d444c8b..a0deda09c622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky. - Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin. - Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall. - Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility. diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 5851e849037e..fc600481e85b 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js"; const createFeishuClientMock = vi.hoisted(() => vi.fn()); const resolveFeishuAccountMock = vi.hoisted(() => vi.fn()); @@ -42,7 +42,7 @@ function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void { expect(pathValue).not.toContain(key); expect(pathValue).not.toContain(".."); - const tmpRoot = path.resolve(os.tmpdir()); + const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir()); const resolved = path.resolve(pathValue); const rel = path.relative(tmpRoot, resolved); expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); diff --git a/src/agents/subagent-announce.format.test.ts b/src/agents/subagent-announce.format.test.ts index b486dff75c81..91f4b0d6752d 100644 --- a/src/agents/subagent-announce.format.test.ts +++ b/src/agents/subagent-announce.format.test.ts @@ -401,6 +401,102 @@ describe("subagent announce formatting", () => { expect(msg).not.toContain("Convert the result above into your normal assistant voice"); }); + it("suppresses completion delivery when subagent reply is ANNOUNCE_SKIP", async () => { + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-completion-skip", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + roundOneReply: "ANNOUNCE_SKIP", + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).not.toHaveBeenCalled(); + expect(agentSpy).not.toHaveBeenCalled(); + }); + + it("suppresses announce flow for whitespace-padded ANNOUNCE_SKIP and still runs cleanup", async () => { + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-skip-whitespace", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + ...defaultOutcomeAnnounce, + cleanup: "delete", + roundOneReply: " ANNOUNCE_SKIP ", + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).not.toHaveBeenCalled(); + expect(agentSpy).not.toHaveBeenCalled(); + expect(sessionsDeleteSpy).toHaveBeenCalledTimes(1); + }); + + it("retries completion direct send on transient channel-unavailable errors", async () => { + sendSpy + .mockRejectedValueOnce(new Error("Error: No active WhatsApp Web listener (account: default)")) + .mockRejectedValueOnce(new Error("UNAVAILABLE: listener reconnecting")) + .mockResolvedValueOnce({ runId: "send-main", status: "ok" }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-completion-retry", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "whatsapp", to: "+15550000000", accountId: "default" }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + roundOneReply: "final answer", + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(3); + expect(agentSpy).not.toHaveBeenCalled(); + }); + + it("does not retry completion direct send on permanent channel errors", async () => { + sendSpy.mockRejectedValueOnce(new Error("unsupported channel: telegram")); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-completion-no-retry", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "telegram", to: "telegram:1234" }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + roundOneReply: "final answer", + }); + + expect(didAnnounce).toBe(false); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(agentSpy).not.toHaveBeenCalled(); + }); + + it("retries direct agent announce on transient channel-unavailable errors", async () => { + agentSpy + .mockRejectedValueOnce(new Error("No active WhatsApp Web listener (account: default)")) + .mockRejectedValueOnce(new Error("UNAVAILABLE: delivery temporarily unavailable")) + .mockResolvedValueOnce({ runId: "run-main", status: "ok" }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-agent-retry", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "whatsapp", to: "+15551112222", accountId: "default" }, + ...defaultOutcomeAnnounce, + roundOneReply: "worker result", + }); + + expect(didAnnounce).toBe(true); + expect(agentSpy).toHaveBeenCalledTimes(3); + expect(sendSpy).not.toHaveBeenCalled(); + }); + it("keeps completion-mode delivery coordinated when sibling runs are still active", async () => { sessionStore = { "agent:main:subagent:test": { diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index c0c981e8e3fc..7d7fd7ceb48b 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -37,12 +37,16 @@ import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import type { SpawnSubagentMode } from "./subagent-spawn.js"; import { readLatestAssistantReply } from "./tools/agent-step.js"; import { sanitizeTextContent, extractAssistantText } from "./tools/sessions-helpers.js"; +import { isAnnounceSkip } from "./tools/sessions-send-helpers.js"; const FAST_TEST_MODE = process.env.OPENCLAW_TEST_FAST === "1"; const FAST_TEST_RETRY_INTERVAL_MS = 8; const FAST_TEST_REPLY_CHANGE_WAIT_MS = 20; const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 60_000; const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000; +const DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS = FAST_TEST_MODE + ? ([8, 16, 32] as const) + : ([5_000, 10_000, 20_000] as const); type ToolResultMessage = { role?: unknown; @@ -72,6 +76,9 @@ function buildCompletionDeliveryMessage(params: { outcome?: SubagentRunOutcome; }): string { const findingsText = params.findings.trim(); + if (isAnnounceSkip(findingsText)) { + return ""; + } const hasFindings = findingsText.length > 0 && findingsText !== "(no output)"; const header = (() => { if (params.outcome?.status === "error") { @@ -111,6 +118,92 @@ function summarizeDeliveryError(error: unknown): string { } } +const TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [ + /\berrorcode=unavailable\b/i, + /\bstatus\s*[:=]\s*"?unavailable\b/i, + /\bUNAVAILABLE\b/, + /no active .* listener/i, + /gateway not connected/i, + /gateway closed \(1006/i, + /gateway timeout/i, + /\b(econnreset|econnrefused|etimedout|enotfound|ehostunreach|network error)\b/i, +]; + +const PERMANENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [ + /unsupported channel/i, + /unknown channel/i, + /chat not found/i, + /user not found/i, + /bot was blocked by the user/i, + /forbidden: bot was kicked/i, + /recipient is not a valid/i, + /outbound not configured for channel/i, +]; + +function isTransientAnnounceDeliveryError(error: unknown): boolean { + const message = summarizeDeliveryError(error); + if (!message) { + return false; + } + if (PERMANENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message))) { + return false; + } + return TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message)); +} + +async function waitForAnnounceRetryDelay(ms: number, signal?: AbortSignal): Promise { + if (ms <= 0) { + return; + } + if (!signal) { + await new Promise((resolve) => setTimeout(resolve, ms)); + return; + } + if (signal.aborted) { + return; + } + await new Promise((resolve) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + resolve(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +async function runAnnounceDeliveryWithRetry(params: { + operation: string; + signal?: AbortSignal; + run: () => Promise; +}): Promise { + let retryIndex = 0; + for (;;) { + if (params.signal?.aborted) { + throw new Error("announce delivery aborted"); + } + try { + return await params.run(); + } catch (err) { + const delayMs = DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS[retryIndex]; + if (delayMs == null || !isTransientAnnounceDeliveryError(err) || params.signal?.aborted) { + throw err; + } + const nextAttempt = retryIndex + 2; + const maxAttempts = DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS.length + 1; + defaultRuntime.log( + `[warn] Subagent announce ${params.operation} transient failure, retrying ${nextAttempt}/${maxAttempts} in ${Math.round(delayMs / 1000)}s: ${summarizeDeliveryError(err)}`, + ); + retryIndex += 1; + await waitForAnnounceRetryDelay(delayMs, params.signal); + } + } +} + function extractToolResultText(content: unknown): string { if (typeof content === "string") { return sanitizeTextContent(content); @@ -712,18 +805,23 @@ async function sendSubagentAnnounceDirectly(params: { path: "none", }; } - await callGateway({ - method: "send", - params: { - channel: completionChannel, - to: completionTo, - accountId: completionDirectOrigin?.accountId, - threadId: completionThreadId, - sessionKey: canonicalRequesterSessionKey, - message: params.completionMessage, - idempotencyKey: params.directIdempotencyKey, - }, - timeoutMs: announceTimeoutMs, + await runAnnounceDeliveryWithRetry({ + operation: "completion direct send", + signal: params.signal, + run: async () => + await callGateway({ + method: "send", + params: { + channel: completionChannel, + to: completionTo, + accountId: completionDirectOrigin?.accountId, + threadId: completionThreadId, + sessionKey: canonicalRequesterSessionKey, + message: params.completionMessage, + idempotencyKey: params.directIdempotencyKey, + }, + timeoutMs: announceTimeoutMs, + }), }); return { @@ -754,21 +852,26 @@ async function sendSubagentAnnounceDirectly(params: { path: "none", }; } - await callGateway({ - method: "agent", - params: { - sessionKey: canonicalRequesterSessionKey, - message: params.triggerMessage, - deliver: shouldDeliverExternally, - bestEffortDeliver: params.bestEffortDeliver, - channel: shouldDeliverExternally ? directChannel : undefined, - accountId: shouldDeliverExternally ? directOrigin?.accountId : undefined, - to: shouldDeliverExternally ? directTo : undefined, - threadId: shouldDeliverExternally ? threadId : undefined, - idempotencyKey: params.directIdempotencyKey, - }, - expectFinal: true, - timeoutMs: announceTimeoutMs, + await runAnnounceDeliveryWithRetry({ + operation: "direct announce agent call", + signal: params.signal, + run: async () => + await callGateway({ + method: "agent", + params: { + sessionKey: canonicalRequesterSessionKey, + message: params.triggerMessage, + deliver: shouldDeliverExternally, + bestEffortDeliver: params.bestEffortDeliver, + channel: shouldDeliverExternally ? directChannel : undefined, + accountId: shouldDeliverExternally ? directOrigin?.accountId : undefined, + to: shouldDeliverExternally ? directTo : undefined, + threadId: shouldDeliverExternally ? threadId : undefined, + idempotencyKey: params.directIdempotencyKey, + }, + expectFinal: true, + timeoutMs: announceTimeoutMs, + }), }); return { @@ -1096,6 +1199,10 @@ export async function runSubagentAnnounceFlow(params: { return false; } + if (isAnnounceSkip(reply)) { + return true; + } + if (!outcome) { outcome = { status: "unknown" }; } diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts index 705656889cb5..bbada8e7bc9d 100644 --- a/src/agents/tool-catalog.ts +++ b/src/agents/tool-catalog.ts @@ -190,7 +190,7 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ label: "cron", description: "Schedule tasks", sectionId: "automation", - profiles: [], + profiles: ["coding"], includeInOpenClawGroup: true, }, { diff --git a/src/agents/tool-policy.test.ts b/src/agents/tool-policy.test.ts index e2fe0a4d1123..9a9f512189b1 100644 --- a/src/agents/tool-policy.test.ts +++ b/src/agents/tool-policy.test.ts @@ -56,6 +56,8 @@ describe("tool-policy", () => { it("resolves known profiles and ignores unknown ones", () => { const coding = resolveToolProfilePolicy("coding"); expect(coding?.allow).toContain("read"); + expect(coding?.allow).toContain("cron"); + expect(coding?.allow).not.toContain("gateway"); expect(resolveToolProfilePolicy("nope")).toBeUndefined(); }); diff --git a/src/gateway/tools-invoke-http.cron-regression.test.ts b/src/gateway/tools-invoke-http.cron-regression.test.ts index a3df263387bb..509df14497f7 100644 --- a/src/gateway/tools-invoke-http.cron-regression.test.ts +++ b/src/gateway/tools-invoke-http.cron-regression.test.ts @@ -120,4 +120,23 @@ describe("tools invoke HTTP denylist", () => { expect(cronRes.status).toBe(200); }); + + it("keeps cron available under coding profile without exposing gateway", async () => { + cfg = { + tools: { + profile: "coding", + }, + gateway: { + tools: { + allow: ["cron"], + }, + }, + }; + + const cronRes = await invoke("cron"); + const gatewayRes = await invoke("gateway"); + + expect(cronRes.status).toBe(200); + expect(gatewayRes.status).toBe(404); + }); }); From a3c4f56b0bf2ef4fde8477a2e96a9ba7e814b1b0 Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Tue, 24 Feb 2026 14:42:00 -0700 Subject: [PATCH 278/408] security(voice-call): detect Telnyx webhook replay --- .../voice-call/src/providers/telnyx.test.ts | 33 +++++++++++++++++ extensions/voice-call/src/providers/telnyx.ts | 2 +- .../voice-call/src/webhook-security.test.ts | 37 ++++++++++++++++++- extensions/voice-call/src/webhook-security.ts | 11 +++++- 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/extensions/voice-call/src/providers/telnyx.test.ts b/extensions/voice-call/src/providers/telnyx.test.ts index e1a4524d2803..7fcd756b9431 100644 --- a/extensions/voice-call/src/providers/telnyx.test.ts +++ b/extensions/voice-call/src/providers/telnyx.test.ts @@ -103,4 +103,37 @@ describe("TelnyxProvider.verifyWebhook", () => { const spkiDerBase64 = spkiDer.toString("base64"); expectWebhookVerificationSucceeds({ publicKey: spkiDerBase64, privateKey }); }); + + it("returns replay status when the same signed request is seen twice", () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer; + const provider = new TelnyxProvider( + { apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDer.toString("base64") }, + { skipVerification: false }, + ); + + const rawBody = JSON.stringify({ + event_type: "call.initiated", + payload: { call_control_id: "call-replay-test" }, + nonce: crypto.randomUUID(), + }); + const timestamp = String(Math.floor(Date.now() / 1000)); + const signedPayload = `${timestamp}|${rawBody}`; + const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64"); + const ctx = createCtx({ + rawBody, + headers: { + "telnyx-signature-ed25519": signature, + "telnyx-timestamp": timestamp, + }, + }); + + const first = provider.verifyWebhook(ctx); + const second = provider.verifyWebhook(ctx); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBeFalsy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + }); }); diff --git a/extensions/voice-call/src/providers/telnyx.ts b/extensions/voice-call/src/providers/telnyx.ts index 05a750a00bb8..e81844f1f659 100644 --- a/extensions/voice-call/src/providers/telnyx.ts +++ b/extensions/voice-call/src/providers/telnyx.ts @@ -87,7 +87,7 @@ export class TelnyxProvider implements VoiceCallProvider { skipVerification: this.options.skipVerification, }); - return { ok: result.ok, reason: result.reason }; + return { ok: result.ok, reason: result.reason, isReplay: result.isReplay }; } /** diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index a047481125f5..e85838a13830 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -1,6 +1,10 @@ import crypto from "node:crypto"; import { describe, expect, it } from "vitest"; -import { verifyPlivoWebhook, verifyTwilioWebhook } from "./webhook-security.js"; +import { + verifyPlivoWebhook, + verifyTelnyxWebhook, + verifyTwilioWebhook, +} from "./webhook-security.js"; function canonicalizeBase64(input: string): string { return Buffer.from(input, "base64").toString("base64"); @@ -199,6 +203,37 @@ describe("verifyPlivoWebhook", () => { }); }); +describe("verifyTelnyxWebhook", () => { + it("marks replayed valid requests as replay without failing auth", () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const pemPublicKey = publicKey.export({ format: "pem", type: "spki" }).toString(); + const timestamp = String(Math.floor(Date.now() / 1000)); + const rawBody = JSON.stringify({ + data: { event_type: "call.initiated", payload: { call_control_id: "call-1" } }, + nonce: crypto.randomUUID(), + }); + const signedPayload = `${timestamp}|${rawBody}`; + const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64"); + const ctx = { + headers: { + "telnyx-signature-ed25519": signature, + "telnyx-timestamp": timestamp, + }, + rawBody, + url: "https://example.com/voice/webhook", + method: "POST" as const, + }; + + const first = verifyTelnyxWebhook(ctx, pemPublicKey); + const second = verifyTelnyxWebhook(ctx, pemPublicKey); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBeFalsy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + }); +}); + describe("verifyTwilioWebhook", () => { it("uses request query when publicUrl omits it", () => { const authToken = "test-auth-token"; diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index cc035b115b8d..d190ed8f9ffa 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -20,6 +20,11 @@ const plivoReplayCache: ReplayCache = { calls: 0, }; +const telnyxReplayCache: ReplayCache = { + seenUntil: new Map(), + calls: 0, +}; + function sha256Hex(input: string): string { return crypto.createHash("sha256").update(input).digest("hex"); } @@ -392,6 +397,8 @@ export interface TwilioVerificationResult { export interface TelnyxVerificationResult { ok: boolean; reason?: string; + /** Request is cryptographically valid but was already processed recently. */ + isReplay?: boolean; } function createTwilioReplayKey(params: { @@ -499,7 +506,9 @@ export function verifyTelnyxWebhook( return { ok: false, reason: "Timestamp too old" }; } - return { ok: true }; + const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${signature}\n${ctx.rawBody}`)}`; + const isReplay = markReplay(telnyxReplayCache, replayKey); + return { ok: true, isReplay }; } catch (err) { return { ok: false, From 7bb08ba94594d89e67c813aaee1d6e55f1d34cd0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 18:49:03 -0500 Subject: [PATCH 279/408] Auto-reply: add exact stop trigger for do not do that --- src/auto-reply/reply/abort.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index 1f3572464e82..3c05fa097b16 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -63,6 +63,7 @@ const ABORT_TRIGGERS = new Set([ "stop dont do anything", "stop do not do anything", "stop doing anything", + "do not do that", "please stop", "stop please", ]); From 91391bbe01ab851f994782a88ced9777c32fb3ca Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 18:49:11 -0500 Subject: [PATCH 280/408] Auto-reply tests: assert exact do not do that behavior --- src/auto-reply/reply/abort.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index e8386f2fa414..68bb923fd163 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -142,6 +142,7 @@ describe("abort detection", () => { "stop dont do anything", "stop do not do anything", "stop doing anything", + "do not do that", "please stop", "stop please", "STOP OPENCLAW", @@ -172,7 +173,7 @@ describe("abort detection", () => { } expect(isAbortTrigger("hello")).toBe(false); - expect(isAbortTrigger("do not do that")).toBe(false); + expect(isAbortTrigger("please do not do that")).toBe(false); // /stop is NOT matched by isAbortTrigger - it's handled separately. expect(isAbortTrigger("/stop")).toBe(false); }); @@ -197,7 +198,8 @@ describe("abort detection", () => { expect(isAbortRequestText("/Stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true); expect(isAbortRequestText("/status")).toBe(false); - expect(isAbortRequestText("do not do that")).toBe(false); + expect(isAbortRequestText("do not do that")).toBe(true); + expect(isAbortRequestText("please do not do that")).toBe(false); expect(isAbortRequestText("/abort")).toBe(false); }); From 83f586b93ba6cc86f7472f7b49db310bc37f06ab Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 18:49:20 -0500 Subject: [PATCH 281/408] Gateway tests: cover exact do not do that stop matching --- src/gateway/chat-abort.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gateway/chat-abort.test.ts b/src/gateway/chat-abort.test.ts index b008d7cc5918..f3aff5ebfe5b 100644 --- a/src/gateway/chat-abort.test.ts +++ b/src/gateway/chat-abort.test.ts @@ -47,6 +47,7 @@ describe("isChatStopCommandText", () => { it("matches slash and standalone multilingual stop forms", () => { expect(isChatStopCommandText(" /STOP!!! ")).toBe(true); expect(isChatStopCommandText("stop please")).toBe(true); + expect(isChatStopCommandText("do not do that")).toBe(true); expect(isChatStopCommandText("停止")).toBe(true); expect(isChatStopCommandText("やめて")).toBe(true); expect(isChatStopCommandText("توقف")).toBe(true); @@ -55,6 +56,7 @@ describe("isChatStopCommandText", () => { expect(isChatStopCommandText("stopp")).toBe(true); expect(isChatStopCommandText("pare")).toBe(true); expect(isChatStopCommandText("/status")).toBe(false); + expect(isChatStopCommandText("please do not do that")).toBe(false); expect(isChatStopCommandText("keep going")).toBe(false); }); }); From cc386f496224860cb839572009605ba73934635c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 18:49:29 -0500 Subject: [PATCH 282/408] Telegram tests: route exact do not do that to control lane --- src/telegram/bot.create-telegram-bot.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index 5bf422506210..ad304efdeab3 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -184,6 +184,11 @@ describe("createTelegramBot", () => { message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop please" }), }), ).toBe("telegram:123:control"); + expect( + getTelegramSequentialKey({ + message: mockMessage({ chat: mockChat({ id: 123 }), text: "do not do that" }), + }), + ).toBe("telegram:123:control"); expect( getTelegramSequentialKey({ message: mockMessage({ chat: mockChat({ id: 123 }), text: "остановись" }), @@ -204,6 +209,11 @@ describe("createTelegramBot", () => { message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort now" }), }), ).toBe("telegram:123"); + expect( + getTelegramSequentialKey({ + message: mockMessage({ chat: mockChat({ id: 123 }), text: "please do not do that" }), + }), + ).toBe("telegram:123"); }); it("routes callback_query payloads as messages and answers callbacks", async () => { createTelegramBot({ token: "tok" }); From de586373e01f407e24a22330a7fb1464a682f561 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 18:49:37 -0500 Subject: [PATCH 283/408] Changelog: note exact do not do that stop trigger --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0deda09c622..39970b1a7979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes -- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), and add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms) so emergency stop messages are caught more reliably. (#25103) Thanks @steipete and @vincentkoc. +- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms), and treat exact `do not do that` as a stop trigger while preserving strict standalone matching. (#25103) Thanks @steipete and @vincentkoc. - Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes). ### Breaking From def993dbd843ff28f2b3bad5cc24603874ba9f1e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:51:01 +0000 Subject: [PATCH 284/408] refactor(tmp): harden temp boundary guardrails --- SECURITY.md | 4 ++ scripts/check-no-random-messaging-tmp.mjs | 7 ++- src/infra/tmp-openclaw-dir.ts | 55 ++++++++++++------- src/media/local-roots.ts | 20 ++++++- src/plugin-sdk/temp-path.ts | 17 +++++- .../check-no-random-messaging-tmp.test.ts | 8 +++ 6 files changed, 84 insertions(+), 27 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index dcda446ad907..fea3cda8357c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -171,6 +171,10 @@ Security boundary notes: - Sandbox media validation allows absolute temp paths only under the OpenClaw-managed temp root. - Arbitrary host tmp paths are not treated as trusted media roots. - Plugin/extension code should use OpenClaw temp helpers (`resolvePreferredOpenClawTmpDir`, `buildRandomTempFilePath`, `withTempDownloadPath`) rather than raw `os.tmpdir()` defaults when handling media files. +- Enforcement reference points: + - temp root resolver: `src/infra/tmp-openclaw-dir.ts` + - SDK temp helpers: `src/plugin-sdk/temp-path.ts` + - messaging/channel tmp guardrail: `scripts/check-no-random-messaging-tmp.mjs` ## Operational Guidance diff --git a/scripts/check-no-random-messaging-tmp.mjs b/scripts/check-no-random-messaging-tmp.mjs index c2d6395f4ddc..af7b56a371fb 100644 --- a/scripts/check-no-random-messaging-tmp.mjs +++ b/scripts/check-no-random-messaging-tmp.mjs @@ -47,7 +47,8 @@ async function collectTypeScriptFiles(dir) { return out; } -function collectNodeOsImports(sourceFile) { +function collectOsTmpdirImports(sourceFile) { + const osModuleSpecifiers = new Set(["node:os", "os"]); const osNamespaceOrDefault = new Set(); const namedTmpdir = new Set(); for (const statement of sourceFile.statements) { @@ -57,7 +58,7 @@ function collectNodeOsImports(sourceFile) { if (!statement.importClause || !ts.isStringLiteral(statement.moduleSpecifier)) { continue; } - if (statement.moduleSpecifier.text !== "node:os") { + if (!osModuleSpecifiers.has(statement.moduleSpecifier.text)) { continue; } const clause = statement.importClause; @@ -101,7 +102,7 @@ function unwrapExpression(expression) { export function findMessagingTmpdirCallLines(content, fileName = "source.ts") { const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); - const { osNamespaceOrDefault, namedTmpdir } = collectNodeOsImports(sourceFile); + const { osNamespaceOrDefault, namedTmpdir } = collectOsTmpdirImports(sourceFile); const lines = []; const visit = (node) => { diff --git a/src/infra/tmp-openclaw-dir.ts b/src/infra/tmp-openclaw-dir.ts index d2377f579618..1e8250b3210f 100644 --- a/src/infra/tmp-openclaw-dir.ts +++ b/src/infra/tmp-openclaw-dir.ts @@ -66,35 +66,48 @@ export function resolvePreferredOpenClawTmpDir( return path.join(base, suffix); }; - try { - const preferred = lstatSync(POSIX_OPENCLAW_TMP_DIR); - if (!preferred.isDirectory() || preferred.isSymbolicLink()) { - return fallback(); - } - accessSync(POSIX_OPENCLAW_TMP_DIR, fs.constants.W_OK | fs.constants.X_OK); - if (!isSecureDirForUser(preferred)) { - return fallback(); + const isTrustedPreferredDir = (st: { + isDirectory(): boolean; + isSymbolicLink(): boolean; + mode?: number; + uid?: number; + }): boolean => { + return st.isDirectory() && !st.isSymbolicLink() && isSecureDirForUser(st); + }; + + const resolvePreferredState = ( + requireWritableAccess: boolean, + ): "available" | "missing" | "invalid" => { + try { + const preferred = lstatSync(POSIX_OPENCLAW_TMP_DIR); + if (!isTrustedPreferredDir(preferred)) { + return "invalid"; + } + if (requireWritableAccess) { + accessSync(POSIX_OPENCLAW_TMP_DIR, fs.constants.W_OK | fs.constants.X_OK); + } + return "available"; + } catch (err) { + if (isNodeErrorWithCode(err, "ENOENT")) { + return "missing"; + } + return "invalid"; } + }; + + const existingPreferredState = resolvePreferredState(true); + if (existingPreferredState === "available") { return POSIX_OPENCLAW_TMP_DIR; - } catch (err) { - if (!isNodeErrorWithCode(err, "ENOENT")) { - return fallback(); - } + } + if (existingPreferredState === "invalid") { + return fallback(); } try { accessSync("/tmp", fs.constants.W_OK | fs.constants.X_OK); // Create with a safe default; subsequent callers expect it exists. mkdirSync(POSIX_OPENCLAW_TMP_DIR, { recursive: true, mode: 0o700 }); - try { - const preferred = lstatSync(POSIX_OPENCLAW_TMP_DIR); - if (!preferred.isDirectory() || preferred.isSymbolicLink()) { - return fallback(); - } - if (!isSecureDirForUser(preferred)) { - return fallback(); - } - } catch { + if (resolvePreferredState(true) !== "available") { return fallback(); } return POSIX_OPENCLAW_TMP_DIR; diff --git a/src/media/local-roots.ts b/src/media/local-roots.ts index 8f203d15f7b0..51476200ca16 100644 --- a/src/media/local-roots.ts +++ b/src/media/local-roots.ts @@ -4,9 +4,25 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; -function buildMediaLocalRoots(stateDir: string): string[] { +type BuildMediaLocalRootsOptions = { + preferredTmpDir?: string; +}; + +let cachedPreferredTmpDir: string | undefined; + +function resolveCachedPreferredTmpDir(): string { + if (!cachedPreferredTmpDir) { + cachedPreferredTmpDir = resolvePreferredOpenClawTmpDir(); + } + return cachedPreferredTmpDir; +} + +function buildMediaLocalRoots( + stateDir: string, + options: BuildMediaLocalRootsOptions = {}, +): string[] { const resolvedStateDir = path.resolve(stateDir); - const preferredTmpDir = resolvePreferredOpenClawTmpDir(); + const preferredTmpDir = options.preferredTmpDir ?? resolveCachedPreferredTmpDir(); return [ preferredTmpDir, path.join(resolvedStateDir, "media"), diff --git a/src/plugin-sdk/temp-path.ts b/src/plugin-sdk/temp-path.ts index f0ca73b2109e..c418fe9f664d 100644 --- a/src/plugin-sdk/temp-path.ts +++ b/src/plugin-sdk/temp-path.ts @@ -31,6 +31,15 @@ function resolveTempRoot(tmpDir?: string): string { return tmpDir ?? resolvePreferredOpenClawTmpDir(); } +function isNodeErrorWithCode(err: unknown, code: string): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: string }).code === code + ); +} + export function buildRandomTempFilePath(params: { prefix: string; extension?: string; @@ -64,6 +73,12 @@ export async function withTempDownloadPath( try { return await fn(tmpPath); } finally { - await rm(dir, { recursive: true, force: true }).catch(() => {}); + try { + await rm(dir, { recursive: true, force: true }); + } catch (err) { + if (!isNodeErrorWithCode(err, "ENOENT")) { + console.warn(`temp-path cleanup failed for ${dir}: ${String(err)}`); + } + } } } diff --git a/test/scripts/check-no-random-messaging-tmp.test.ts b/test/scripts/check-no-random-messaging-tmp.test.ts index 01495b2b09bc..276a19962af3 100644 --- a/test/scripts/check-no-random-messaging-tmp.test.ts +++ b/test/scripts/check-no-random-messaging-tmp.test.ts @@ -18,6 +18,14 @@ describe("check-no-random-messaging-tmp", () => { expect(findMessagingTmpdirCallLines(source)).toEqual([3]); }); + it("finds tmpdir calls imported from os", () => { + const source = ` + import os from "os"; + const dir = os.tmpdir(); + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([3]); + }); + it("ignores mentions in comments and strings", () => { const source = ` // os.tmpdir() From e22a2d77baf349c38fb1e845f54001a72de267e1 Mon Sep 17 00:00:00 2001 From: Mark Musson Date: Tue, 24 Feb 2026 19:28:47 +0000 Subject: [PATCH 285/408] fix(whatsapp): stop retry loop on non-retryable 440 close --- ....reconnects-after-connection-close.test.ts | 50 +++++++++++++++++++ src/web/auto-reply/monitor.ts | 22 ++++++++ 2 files changed, 72 insertions(+) diff --git a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts index f6eca287621d..678cf0d37c61 100644 --- a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts +++ b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts @@ -145,6 +145,56 @@ describe("web auto-reply", () => { expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining(scenario.expectedError)); } }); + + it("treats status 440 as non-retryable and stops without retrying", async () => { + const closeResolvers: Array<(reason?: unknown) => void> = []; + const sleep = vi.fn(async () => {}); + const listenerFactory = vi.fn(async () => { + const onClose = new Promise((res) => { + closeResolvers.push(res); + }); + return { close: vi.fn(), onClose }; + }); + const { runtime, controller, run } = startMonitorWebChannel({ + monitorWebChannelFn: monitorWebChannel as never, + listenerFactory, + sleep, + reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 }, + }); + + await Promise.resolve(); + expect(listenerFactory).toHaveBeenCalledTimes(1); + closeResolvers.shift()?.({ + status: 440, + isLoggedOut: false, + error: "Unknown Stream Errored (conflict)", + }); + + const completedQuickly = await Promise.race([ + run.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 60)), + ]); + + if (!completedQuickly) { + await vi.waitFor( + () => { + expect(listenerFactory).toHaveBeenCalledTimes(2); + }, + { timeout: 250, interval: 2 }, + ); + controller.abort(); + closeResolvers[1]?.({ status: 499, isLoggedOut: false, error: "aborted" }); + await run; + } + + expect(completedQuickly).toBe(true); + expect(listenerFactory).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("status 440")); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("session conflict")); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Stopping web monitoring")); + }); + it("forces reconnect when watchdog closes without onClose", async () => { vi.useFakeTimers(); try { diff --git a/src/web/auto-reply/monitor.ts b/src/web/auto-reply/monitor.ts index cab3490feddc..b7e2bb2683f1 100644 --- a/src/web/auto-reply/monitor.ts +++ b/src/web/auto-reply/monitor.ts @@ -31,6 +31,12 @@ import { createWebOnMessageHandler } from "./monitor/on-message.js"; import type { WebChannelStatus, WebInboundMsg, WebMonitorTuning } from "./types.js"; import { isLikelyWhatsAppCryptoError } from "./util.js"; +function isNonRetryableWebCloseStatus(statusCode: unknown): boolean { + // WhatsApp 440 = session conflict ("Unknown Stream Errored (conflict)"). + // This is persistent until the operator resolves the conflicting session. + return statusCode === 440; +} + export async function monitorWebChannel( verbose: boolean, listenerFactory: typeof monitorWebInbox | undefined = monitorWebInbox, @@ -402,6 +408,22 @@ export async function monitorWebChannel( break; } + if (isNonRetryableWebCloseStatus(statusCode)) { + reconnectLogger.warn( + { + connectionId, + status: statusCode, + error: errorStr, + }, + "web reconnect: non-retryable close status; stopping monitor", + ); + runtime.error( + `WhatsApp Web connection closed (status ${statusCode}: session conflict). Resolve conflicting WhatsApp Web sessions, then relink with \`${formatCliCommand("openclaw channels login --channel web")}\`. Stopping web monitoring.`, + ); + await closeListener(); + break; + } + reconnectAttempts += 1; status.reconnectAttempts = reconnectAttempts; emitStatus(); From b0bb3cca8a406f57ae7bc4220912976bfe1e028e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:54:45 +0000 Subject: [PATCH 286/408] test(types): fix ts narrowing regressions in followup and matrix queue tests --- extensions/matrix/src/matrix/send-queue.test.ts | 8 +++++++- src/auto-reply/reply/followup-runner.test.ts | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/extensions/matrix/src/matrix/send-queue.test.ts b/extensions/matrix/src/matrix/send-queue.test.ts index bc90c5f50ab5..aa4765eaab34 100644 --- a/extensions/matrix/src/matrix/send-queue.test.ts +++ b/extensions/matrix/src/matrix/send-queue.test.ts @@ -79,8 +79,11 @@ describe("enqueueSend", () => { await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); const firstResult = await first; expect(firstResult.ok).toBe(false); + if (firstResult.ok) { + throw new Error("expected first queue item to fail"); + } expect(firstResult.error).toBeInstanceOf(Error); - expect((firstResult.error as Error).message).toBe("boom"); + expect(firstResult.error.message).toBe("boom"); const second = enqueueSend("!room:example.org", async () => "ok"); await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); @@ -110,6 +113,9 @@ describe("enqueueSend", () => { gate.resolve(); const firstResult = await first; expect(firstResult.ok).toBe(false); + if (firstResult.ok) { + throw new Error("expected head queue item to fail"); + } expect(firstResult.error).toBeInstanceOf(Error); await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index a9d4249e597c..7627c79a5990 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -64,7 +64,7 @@ const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun => }) as FollowupRun; function createQueuedRun( - overrides: Partial & { run?: Partial } = {}, + overrides: Partial> & { run?: Partial } = {}, ): FollowupRun { const base = baseQueuedRun(); return { From b3e66535036f4b71c4f46083a8e057560eb78f41 Mon Sep 17 00:00:00 2001 From: suko Date: Tue, 24 Feb 2026 21:56:49 +0100 Subject: [PATCH 287/408] fix(onboard): avoid false 'telegram plugin not available' block --- src/commands/onboard-channels.test.ts | 50 +++++++++++++++++++++++++++ src/commands/onboard-channels.ts | 14 ++++++++ src/commands/onboarding/registry.ts | 34 ++++++++++++++---- 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/src/commands/onboard-channels.test.ts b/src/commands/onboard-channels.test.ts index d263ff9c0b28..d6c0669e4fd7 100644 --- a/src/commands/onboard-channels.test.ts +++ b/src/commands/onboard-channels.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js"; import { setupChannels } from "./onboard-channels.js"; @@ -42,6 +44,15 @@ vi.mock("./onboard-helpers.js", () => ({ detectBinary: vi.fn(async () => false), })); +vi.mock("./onboarding/plugin-install.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as Record), + // Allow tests to simulate an empty plugin registry during onboarding. + reloadOnboardingPluginRegistry: vi.fn(() => {}), + }; +}); + describe("setupChannels", () => { beforeEach(() => { setDefaultChannelPluginRegistryForTests(); @@ -81,6 +92,45 @@ describe("setupChannels", () => { expect(multiselect).not.toHaveBeenCalled(); }); + it("continues Telegram onboarding even when plugin registry is empty (avoids 'plugin not available' block)", async () => { + // Simulate missing registry entries (the scenario reported in #25545). + setActivePluginRegistry(createEmptyPluginRegistry()); + // Avoid accidental env-token configuration changing the prompt path. + process.env.TELEGRAM_BOT_TOKEN = ""; + + const note = vi.fn(async (_message?: string, _title?: string) => {}); + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "telegram"; + } + return "__done__"; + }); + const text = vi.fn(async () => "123:token"); + + const prompter = createPrompter({ + note, + select: select as unknown as WizardPrompter["select"], + text: text as unknown as WizardPrompter["text"], + }); + + const runtime = createExitThrowingRuntime(); + + await setupChannels({} as OpenClawConfig, runtime, prompter, { + skipConfirm: true, + quickstartDefaults: true, + }); + + // The new flow should not stop setup with a hard "plugin not available" note. + const sawHardStop = note.mock.calls.some((call) => { + const message = call[0]; + const title = call[1]; + return ( + title === "Channel setup" && String(message).trim() === "telegram plugin not available." + ); + }); + expect(sawHardStop).toBe(false); + }); + it("shows explicit dmScope config command in channel primer", async () => { const note = vi.fn(async (_message?: string, _title?: string) => {}); const select = vi.fn(async () => "__done__"); diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 1ac763d9f019..32510c29f39d 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -467,6 +467,20 @@ export async function setupChannels( workspaceDir, }); if (!getChannelPlugin(channel)) { + // Some installs/environments can fail to populate the plugin registry during onboarding, + // even for built-in channels. If the channel supports onboarding, proceed with config + // so setup isn't blocked; the gateway can still load plugins on startup. + const adapter = getChannelOnboardingAdapter(channel); + if (adapter) { + await prompter.note( + `${channel} plugin not available (continuing with onboarding). If the channel still doesn't work after setup, run \`${formatCliCommand( + "openclaw plugins list", + )}\` and \`${formatCliCommand("openclaw plugins enable " + channel)}\`, then restart the gateway.`, + "Channel setup", + ); + await refreshStatus(channel); + return true; + } await prompter.note(`${channel} plugin not available.`, "Channel setup"); return false; } diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index d3fdbef2ce9a..814eab75ea24 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -1,16 +1,36 @@ import { listChannelPlugins } from "../../channels/plugins/index.js"; +import { discordOnboardingAdapter } from "../../channels/plugins/onboarding/discord.js"; +import { imessageOnboardingAdapter } from "../../channels/plugins/onboarding/imessage.js"; +import { signalOnboardingAdapter } from "../../channels/plugins/onboarding/signal.js"; +import { slackOnboardingAdapter } from "../../channels/plugins/onboarding/slack.js"; +import { telegramOnboardingAdapter } from "../../channels/plugins/onboarding/telegram.js"; +import { whatsappOnboardingAdapter } from "../../channels/plugins/onboarding/whatsapp.js"; import type { ChannelChoice } from "../onboard-types.js"; import type { ChannelOnboardingAdapter } from "./types.js"; -const CHANNEL_ONBOARDING_ADAPTERS = () => - new Map( - listChannelPlugins() - .map((plugin) => (plugin.onboarding ? ([plugin.id, plugin.onboarding] as const) : null)) - .filter((entry): entry is readonly [ChannelChoice, ChannelOnboardingAdapter] => - Boolean(entry), - ), +const BUILTIN_ONBOARDING_ADAPTERS: ChannelOnboardingAdapter[] = [ + telegramOnboardingAdapter, + whatsappOnboardingAdapter, + discordOnboardingAdapter, + slackOnboardingAdapter, + signalOnboardingAdapter, + imessageOnboardingAdapter, +]; + +const CHANNEL_ONBOARDING_ADAPTERS = () => { + const fromRegistry = listChannelPlugins() + .map((plugin) => (plugin.onboarding ? ([plugin.id, plugin.onboarding] as const) : null)) + .filter((entry): entry is readonly [ChannelChoice, ChannelOnboardingAdapter] => Boolean(entry)); + + // Fall back to built-in adapters to keep onboarding working even when the plugin registry + // fails to populate (see #25545). + const fromBuiltins = BUILTIN_ONBOARDING_ADAPTERS.map( + (adapter) => [adapter.channel, adapter] as const, ); + return new Map([...fromBuiltins, ...fromRegistry]); +}; + export function getChannelOnboardingAdapter( channel: ChannelChoice, ): ChannelOnboardingAdapter | undefined { From b7deb062eafa18dd4d77d9b2c2d9669b75bf21c0 Mon Sep 17 00:00:00 2001 From: Fred White Date: Tue, 24 Feb 2026 00:03:00 -0500 Subject: [PATCH 288/408] fix: normalize "bedrock" provider ID to "amazon-bedrock" Add "bedrock" and "aws-bedrock" as aliases for the canonical "amazon-bedrock" provider ID in normalizeProviderId(). Without this mapping, configuring a model as "bedrock/..." causes the auth resolution fallback to miss the Bedrock-specific AWS SDK path, since the fallback check requires normalized === "amazon-bedrock". This primarily affects the main agent when the explicit auth override is not preserved through config merging. Fixes #15716 --- src/agents/model-auth.test.ts | 12 ++++++++++++ src/agents/model-selection.test.ts | 3 +++ src/agents/model-selection.ts | 3 +++ 3 files changed, 18 insertions(+) diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 2c93ee0723dc..86bc6bba5a04 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -77,6 +77,18 @@ describe("resolveModelAuthMode", () => { ), ).toBe("aws-sdk"); }); + + it("returns aws-sdk for bedrock alias without explicit auth override", () => { + expect(resolveModelAuthMode("bedrock", undefined, { version: 1, profiles: {} })).toBe( + "aws-sdk", + ); + }); + + it("returns aws-sdk for aws-bedrock alias without explicit auth override", () => { + expect(resolveModelAuthMode("aws-bedrock", undefined, { version: 1, profiles: {} })).toBe( + "aws-sdk", + ); + }); }); describe("requireApiKey", () => { diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index df4298636c70..3c2f7edf2794 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -19,6 +19,9 @@ describe("model-selection", () => { expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode"); expect(normalizeProviderId("qwen")).toBe("qwen-portal"); expect(normalizeProviderId("kimi-code")).toBe("kimi-coding"); + expect(normalizeProviderId("bedrock")).toBe("amazon-bedrock"); + expect(normalizeProviderId("aws-bedrock")).toBe("amazon-bedrock"); + expect(normalizeProviderId("amazon-bedrock")).toBe("amazon-bedrock"); }); }); diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index acdc2faf119a..2eb2f8e9c557 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -50,6 +50,9 @@ export function normalizeProviderId(provider: string): string { if (normalized === "kimi-code") { return "kimi-coding"; } + if (normalized === "bedrock" || normalized === "aws-bedrock") { + return "amazon-bedrock"; + } // Backward compatibility for older provider naming. if (normalized === "bytedance" || normalized === "doubao") { return "volcengine"; From 8680240f7e1374a2da709168e8a4379daae687ab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:58:53 +0000 Subject: [PATCH 289/408] docs(changelog): backfill landed fix PR entries --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39970b1a7979..49095b6d75a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Security: enforce gateway auth for the exact `/api/channels` plugin root path (plus `/api/channels/` descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3. +- Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (`LD_*`, `DYLD_*`, `SSLKEYLOGFILE`, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3. +- Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width `HOOK:...`) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3. +- Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3. +- WhatsApp/Web reconnect: treat close status `440` as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson. +- Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko. +- Models/Bedrock auth: normalize additional Bedrock provider aliases (`bedrock`, `aws-bedrock`, `aws_bedrock`, `amazon bedrock`) to canonical `amazon-bedrock`, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13. - Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky. - Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin. - Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall. @@ -599,7 +606,6 @@ Docs: https://docs.openclaw.ai - Security/Media: harden local media ingestion against TOCTOU/symlink swap attacks by pinning reads to a single file descriptor with symlink rejection and inode/device verification in `saveMediaSource`. Thanks @dorjoos for reporting. - Security/Lobster (Windows): for the next npm release, remove shell-based fallback when launching Lobster wrappers (`.cmd`/`.bat`) and switch to explicit argv execution with wrapper entrypoint resolution, preventing command injection while preserving Windows wrapper compatibility. Thanks @allsmog for reporting. - Security/Exec: require `tools.exec.safeBins` binaries to resolve from trusted bin directories (system defaults plus gateway startup `PATH`) so PATH-hijacked trojan binaries cannot bypass allowlist checks. Thanks @jackhax for reporting. -- Security/Exec: sanitize inherited host execution environment before merge and strip dangerous keys (`LD_*`, `DYLD_*`, `SSLKEYLOGFILE`, and related injection vectors) from non-sandboxed exec runs. (#9792) - Security/Exec: remove file-existence oracle behavior from `tools.exec.safeBins` by using deterministic argv-only stdin-safe validation and blocking file-oriented flags (for example `sort -o`, `jq -f`, `grep -f`) so allow/deny results no longer disclose host file presence. Thanks @nedlir for reporting. - Security/Browser: route browser URL navigation through one SSRF-guarded validation path for tab-open/CDP-target/Playwright navigation flows and block private/metadata destinations by default (configurable via `browser.ssrfPolicy`). Thanks @dorjoos for reporting. - Security/Exec: for the next npm release, harden safe-bin stdin-only enforcement by blocking output/recursive flags (`sort -o/--output`, grep recursion) and tightening default safe bins to remove `sort`/`grep`, preventing safe-bin allowlist bypass for file writes/recursive reads. Thanks @nedlir for reporting. From 55cf92578d266987e390c4bf688196af98eac748 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:01:53 +0000 Subject: [PATCH 290/408] fix(security): harden system.run companion command binding --- CHANGELOG.md | 1 + .../OpenClaw/ExecApprovalsSocket.swift | 27 +- .../ExecSystemRunCommandValidator.swift | 416 ++++++++++++++++++ .../ExecSystemRunCommandValidatorTests.swift | 50 +++ src/node-host/invoke-system-run.test.ts | 26 ++ src/node-host/invoke-system-run.ts | 5 +- 6 files changed, 520 insertions(+), 5 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift create mode 100644 apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 49095b6d75a2..ae2459d479e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so `dmPolicy: "allowlist"` with empty `allowedUserIds` rejects all senders instead of allowing unauthorized dispatch. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. This ships in the next npm release. - Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. This ships in the next npm release. Thanks @tdjackey for reporting. - Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. - Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift index 362a7da01d88..130e94731177 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -361,7 +361,24 @@ private enum ExecHostExecutor { reason: "invalid") } - let context = await self.buildContext(request: request, command: command) + let validatedCommand = ExecSystemRunCommandValidator.resolve( + command: command, + rawCommand: request.rawCommand) + let displayCommand: String + switch validatedCommand { + case .ok(let resolved): + displayCommand = resolved.displayCommand + case .invalid(let message): + return self.errorResponse( + code: "INVALID_REQUEST", + message: message, + reason: "invalid") + } + + let context = await self.buildContext( + request: request, + command: command, + rawCommand: displayCommand) if context.security == .deny { return self.errorResponse( code: "UNAVAILABLE", @@ -451,10 +468,14 @@ private enum ExecHostExecutor { timeoutMs: request.timeoutMs) } - private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext { + private static func buildContext( + request: ExecHostRequest, + command: [String], + rawCommand: String?) async -> ExecApprovalContext + { await ExecApprovalEvaluator.evaluate( command: command, - rawCommand: request.rawCommand, + rawCommand: rawCommand, cwd: request.cwd, envOverrides: request.env, agentId: request.agentId) diff --git a/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift new file mode 100644 index 000000000000..f3bdac5e71e7 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift @@ -0,0 +1,416 @@ +import Foundation + +enum ExecSystemRunCommandValidator { + struct ResolvedCommand { + let displayCommand: String + } + + enum ValidationResult { + case ok(ResolvedCommand) + case invalid(message: String) + } + + private static let shellWrapperNames = Set([ + "ash", + "bash", + "cmd", + "dash", + "fish", + "ksh", + "powershell", + "pwsh", + "sh", + "zsh", + ]) + + private static let posixOrPowerShellInlineWrapperNames = Set([ + "ash", + "bash", + "dash", + "fish", + "ksh", + "powershell", + "pwsh", + "sh", + "zsh", + ]) + + private static let shellMultiplexerWrapperNames = Set(["busybox", "toybox"]) + private static let posixInlineCommandFlags = Set(["-lc", "-c", "--command"]) + private static let powershellInlineCommandFlags = Set(["-c", "-command", "--command"]) + + private static let envOptionsWithValue = Set([ + "-u", + "--unset", + "-c", + "--chdir", + "-s", + "--split-string", + "--default-signal", + "--ignore-signal", + "--block-signal", + ]) + private static let envFlagOptions = Set(["-i", "--ignore-environment", "-0", "--null"]) + private static let envInlineValuePrefixes = [ + "-u", + "-c", + "-s", + "--unset=", + "--chdir=", + "--split-string=", + "--default-signal=", + "--ignore-signal=", + "--block-signal=", + ] + + private struct EnvUnwrapResult { + let argv: [String] + let usesModifiers: Bool + } + + static func resolve(command: [String], rawCommand: String?) -> ValidationResult { + let normalizedRaw = self.normalizeRaw(rawCommand) + let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil) + let shellCommand = shell.isWrapper ? self.trimmedNonEmpty(shell.command) : nil + + let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command) + let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command) + let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv + + let inferred: String + if let shellCommand, !mustBindDisplayToFullArgv { + inferred = shellCommand + } else { + inferred = ExecCommandFormatter.displayString(for: command) + } + + if let raw = normalizedRaw, raw != inferred { + return .invalid(message: "INVALID_REQUEST: rawCommand does not match command") + } + + return .ok(ResolvedCommand(displayCommand: normalizedRaw ?? inferred)) + } + + private static func normalizeRaw(_ rawCommand: String?) -> String? { + let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func trimmedNonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func normalizeExecutableToken(_ token: String) -> String { + let base = ExecCommandToken.basenameLower(token) + if base.hasSuffix(".exe") { + return String(base.dropLast(4)) + } + return base + } + + private static func isEnvAssignment(_ token: String) -> Bool { + token.range(of: #"^[A-Za-z_][A-Za-z0-9_]*=.*"#, options: .regularExpression) != nil + } + + private static func hasEnvInlineValuePrefix(_ lowerToken: String) -> Bool { + self.envInlineValuePrefixes.contains { lowerToken.hasPrefix($0) } + } + + private static func unwrapEnvInvocationWithMetadata(_ argv: [String]) -> EnvUnwrapResult? { + var idx = 1 + var expectsOptionValue = false + var usesModifiers = false + + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + if expectsOptionValue { + expectsOptionValue = false + usesModifiers = true + idx += 1 + continue + } + if token == "--" || token == "-" { + idx += 1 + break + } + if self.isEnvAssignment(token) { + usesModifiers = true + idx += 1 + continue + } + if !token.hasPrefix("-") || token == "-" { + break + } + + let lower = token.lowercased() + let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower + if self.envFlagOptions.contains(flag) { + usesModifiers = true + idx += 1 + continue + } + if self.envOptionsWithValue.contains(flag) { + usesModifiers = true + if !lower.contains("=") { + expectsOptionValue = true + } + idx += 1 + continue + } + if self.hasEnvInlineValuePrefix(lower) { + usesModifiers = true + idx += 1 + continue + } + return nil + } + + if expectsOptionValue { + return nil + } + guard idx < argv.count else { + return nil + } + return EnvUnwrapResult(argv: Array(argv[idx...]), usesModifiers: usesModifiers) + } + + private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? { + guard let token0 = self.trimmedNonEmpty(argv.first) else { + return nil + } + let wrapper = self.normalizeExecutableToken(token0) + guard self.shellMultiplexerWrapperNames.contains(wrapper) else { + return nil + } + + var appletIndex = 1 + if appletIndex < argv.count && argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" { + appletIndex += 1 + } + guard appletIndex < argv.count else { + return nil + } + let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) + guard !applet.isEmpty else { + return nil + } + let normalizedApplet = self.normalizeExecutableToken(applet) + guard self.shellWrapperNames.contains(normalizedApplet) else { + return nil + } + return Array(argv[appletIndex...]) + } + + private static func hasEnvManipulationBeforeShellWrapper( + _ argv: [String], + depth: Int = 0, + envManipulationSeen: Bool = false) -> Bool + { + if depth >= ExecEnvInvocationUnwrapper.maxWrapperDepth { + return false + } + guard let token0 = self.trimmedNonEmpty(argv.first) else { + return false + } + + let normalized = self.normalizeExecutableToken(token0) + if normalized == "env" { + guard let envUnwrap = self.unwrapEnvInvocationWithMetadata(argv) else { + return false + } + return self.hasEnvManipulationBeforeShellWrapper( + envUnwrap.argv, + depth: depth + 1, + envManipulationSeen: envManipulationSeen || envUnwrap.usesModifiers) + } + + if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(argv) { + return self.hasEnvManipulationBeforeShellWrapper( + shellMultiplexer, + depth: depth + 1, + envManipulationSeen: envManipulationSeen) + } + + guard self.shellWrapperNames.contains(normalized) else { + return false + } + guard self.extractShellInlinePayload(argv, normalizedWrapper: normalized) != nil else { + return false + } + return envManipulationSeen + } + + private static func hasTrailingPositionalArgvAfterInlineCommand(_ argv: [String]) -> Bool { + let wrapperArgv = self.unwrapShellWrapperArgv(argv) + guard let token0 = self.trimmedNonEmpty(wrapperArgv.first) else { + return false + } + let wrapper = self.normalizeExecutableToken(token0) + guard self.posixOrPowerShellInlineWrapperNames.contains(wrapper) else { + return false + } + + let inlineCommandIndex: Int? + if wrapper == "powershell" || wrapper == "pwsh" { + inlineCommandIndex = self.resolveInlineCommandTokenIndex( + wrapperArgv, + flags: self.powershellInlineCommandFlags, + allowCombinedC: false) + } else { + inlineCommandIndex = self.resolveInlineCommandTokenIndex( + wrapperArgv, + flags: self.posixInlineCommandFlags, + allowCombinedC: true) + } + guard let inlineCommandIndex else { + return false + } + let start = inlineCommandIndex + 1 + guard start < wrapperArgv.count else { + return false + } + return wrapperArgv[start...].contains { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } + + private static func unwrapShellWrapperArgv(_ argv: [String]) -> [String] { + var current = argv + for _ in 0.., + allowCombinedC: Bool) -> Int? + { + var idx = 1 + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + let lower = token.lowercased() + if lower == "--" { + break + } + if flags.contains(lower) { + return idx + 1 < argv.count ? idx + 1 : nil + } + if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) { + let inline = String(token.dropFirst(inlineOffset)) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !inline.isEmpty { + return idx + } + return idx + 1 < argv.count ? idx + 1 : nil + } + idx += 1 + } + return nil + } + + private static func combinedCommandInlineOffset(_ token: String) -> Int? { + let chars = Array(token.lowercased()) + guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else { + return nil + } + if chars.dropFirst().contains("-") { + return nil + } + guard let commandIndex = chars.firstIndex(of: "c"), commandIndex > 0 else { + return nil + } + return commandIndex + 1 + } + + private static func extractShellInlinePayload( + _ argv: [String], + normalizedWrapper: String) -> String? + { + if normalizedWrapper == "cmd" { + return self.extractCmdInlineCommand(argv) + } + if normalizedWrapper == "powershell" || normalizedWrapper == "pwsh" { + return self.extractInlineCommandByFlags( + argv, + flags: self.powershellInlineCommandFlags, + allowCombinedC: false) + } + return self.extractInlineCommandByFlags( + argv, + flags: self.posixInlineCommandFlags, + allowCombinedC: true) + } + + private static func extractInlineCommandByFlags( + _ argv: [String], + flags: Set, + allowCombinedC: Bool) -> String? + { + var idx = 1 + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + let lower = token.lowercased() + if lower == "--" { + break + } + if flags.contains(lower) { + return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil) + } + if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) { + let inline = String(token.dropFirst(inlineOffset)) + if let inlineValue = self.trimmedNonEmpty(inline) { + return inlineValue + } + return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil) + } + idx += 1 + } + return nil + } + + private static func extractCmdInlineCommand(_ argv: [String]) -> String? { + guard let idx = argv.firstIndex(where: { + let token = $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return token == "/c" || token == "/k" + }) else { + return nil + } + let tailIndex = idx + 1 + guard tailIndex < argv.count else { + return nil + } + let payload = argv[tailIndex...].joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + return payload.isEmpty ? nil : payload + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift new file mode 100644 index 000000000000..c6ad3319a65e --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import OpenClaw + +struct ExecSystemRunCommandValidatorTests { + @Test func rejectsPayloadOnlyRawForPositionalCarrierWrappers() { + let command = ["/bin/sh", "-lc", #"$0 "$1""#, "/usr/bin/touch", "/tmp/marker"] + let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: #"$0 "$1""#) + switch result { + case .ok: + Issue.record("expected rawCommand mismatch") + case .invalid(let message): + #expect(message.contains("rawCommand does not match command")) + } + } + + @Test func acceptsCanonicalDisplayForPositionalCarrierWrappers() { + let command = ["/bin/sh", "-lc", #"$0 "$1""#, "/usr/bin/touch", "/tmp/marker"] + let expected = ExecCommandFormatter.displayString(for: command) + let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: expected) + switch result { + case .ok(let resolved): + #expect(resolved.displayCommand == expected) + case .invalid(let message): + Issue.record("unexpected validation failure: \(message)") + } + } + + @Test func acceptsShellPayloadRawForTransparentEnvWrapper() { + let command = ["/usr/bin/env", "bash", "-lc", "echo hi"] + let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: "echo hi") + switch result { + case .ok(let resolved): + #expect(resolved.displayCommand == "echo hi") + case .invalid(let message): + Issue.record("unexpected validation failure: \(message)") + } + } + + @Test func rejectsShellPayloadRawForEnvModifierPrelude() { + let command = ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"] + let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: "echo hi") + switch result { + case .ok: + Issue.record("expected rawCommand mismatch") + case .invalid(let message): + #expect(message.contains("rawCommand does not match command")) + } + } +} diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 2c6c55bd1abd..a3be712655da 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -121,6 +121,32 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { ); }); + it("forwards canonical cmdText to mac app exec host for positional-argv shell wrappers", async () => { + const { runViaMacAppExecHost } = await runSystemInvoke({ + preferMacAppExecHost: true, + command: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"], + runViaResponse: { + ok: true, + payload: { + success: true, + stdout: "app-ok", + stderr: "", + timedOut: false, + exitCode: 0, + error: null, + }, + }, + }); + + expect(runViaMacAppExecHost).toHaveBeenCalledWith({ + approvals: expect.anything(), + request: expect.objectContaining({ + command: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"], + rawCommand: '/bin/sh -lc "$0 \\"$1\\"" /usr/bin/touch /tmp/marker', + }), + }); + }); + it("handles transparent env wrappers in allowlist mode", async () => { const { runCommand, sendInvokeResult } = await runSystemInvoke({ preferMacAppExecHost: false, diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index 897d8ebd111b..c9b107a5d313 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -291,7 +291,6 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): } const argv = command.argv; - const rawCommand = command.rawCommand ?? ""; const shellCommand = command.shellCommand; const cmdText = command.cmdText; const agentId = opts.params.agentId?.trim() || undefined; @@ -388,7 +387,9 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): if (useMacAppExec) { const execRequest: ExecHostRequest = { command: plannedAllowlistArgv ?? argv, - rawCommand: rawCommand || shellCommand || null, + // Forward canonical display text so companion approval/prompt surfaces bind to + // the exact command context already validated on the node-host. + rawCommand: cmdText || null, cwd: opts.params.cwd ?? null, env: envOverrides ?? null, timeoutMs: opts.params.timeoutMs ?? null, From 97e56cb73cd3a22e6399250c02b30efe39fb74c4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:03:21 +0000 Subject: [PATCH 291/408] fix(discord): land proxy/media/reaction/model-picker regressions Reimplements core Discord fixes from #25277 #25523 #25575 #25588 #25731 with expanded tests. - thread proxy-aware fetch into inbound attachment/sticker downloads - fetch /gateway/bot via proxy dispatcher before ws connect - wire statusReactions emojis/timing overrides into controller - compact model-picker custom_id keys with backward-compatible parsing Co-authored-by: openperf Co-authored-by: chilu18 Co-authored-by: Yipsh Co-authored-by: lbo728 Co-authored-by: s1korrrr --- CHANGELOG.md | 1 + src/discord/monitor/gateway-plugin.ts | 29 ++++++- .../monitor/message-handler.preflight.ts | 1 + .../message-handler.preflight.types.ts | 2 + .../monitor/message-handler.process.test.ts | 29 +++++++ .../monitor/message-handler.process.ts | 11 ++- src/discord/monitor/message-utils.test.ts | 64 +++++++++++++++ src/discord/monitor/message-utils.ts | 12 ++- src/discord/monitor/model-picker.test.ts | 41 +++++++++- src/discord/monitor/model-picker.ts | 16 ++-- src/discord/monitor/provider.proxy.test.ts | 81 +++++++++++++++++-- src/discord/monitor/provider.ts | 1 + 12 files changed, 265 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae2459d479e2..f64fc5f29d5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko. - Models/Bedrock auth: normalize additional Bedrock provider aliases (`bedrock`, `aws-bedrock`, `aws_bedrock`, `amazon bedrock`) to canonical `amazon-bedrock`, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13. - Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky. +- Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire `messages.statusReactions.{emojis,timing}` into Discord reaction lifecycle control, and compact model-picker `custom_id` keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr. - Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin. - Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall. - Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility. diff --git a/src/discord/monitor/gateway-plugin.ts b/src/discord/monitor/gateway-plugin.ts index 74e1aad8630c..c86b6259c5e8 100644 --- a/src/discord/monitor/gateway-plugin.ts +++ b/src/discord/monitor/gateway-plugin.ts @@ -1,5 +1,7 @@ import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway"; +import type { APIGatewayBotInfo } from "discord-api-types/v10"; import { HttpsProxyAgent } from "https-proxy-agent"; +import { ProxyAgent, fetch as undiciFetch } from "undici"; import WebSocket from "ws"; import type { DiscordAccountConfig } from "../../config/types.js"; import { danger } from "../../globals.js"; @@ -42,7 +44,8 @@ export function createDiscordGatewayPlugin(params: { } try { - const agent = new HttpsProxyAgent(proxy); + const wsAgent = new HttpsProxyAgent(proxy); + const fetchAgent = new ProxyAgent(proxy); params.runtime.log?.("discord: gateway proxy enabled"); @@ -51,8 +54,28 @@ export function createDiscordGatewayPlugin(params: { super(options); } - createWebSocket(url: string) { - return new WebSocket(url, { agent }); + override async registerClient(client: Parameters[0]) { + if (!this.gatewayInfo) { + try { + const response = await undiciFetch("https://discord.com/api/v10/gateway/bot", { + headers: { + Authorization: `Bot ${client.options.token}`, + }, + dispatcher: fetchAgent, + } as Record); + this.gatewayInfo = (await response.json()) as APIGatewayBotInfo; + } catch (error) { + throw new Error( + `Failed to get gateway information from Discord: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + } + return super.registerClient(client); + } + + override createWebSocket(url: string) { + return new WebSocket(url, { agent: wsAgent }); } } diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index e321c8ef86f1..88871b006831 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -733,5 +733,6 @@ export async function preflightDiscordMessage( canDetectMention, historyEntry, threadBindings: params.threadBindings, + discordRestFetch: params.discordRestFetch, }; } diff --git a/src/discord/monitor/message-handler.preflight.types.ts b/src/discord/monitor/message-handler.preflight.types.ts index 86a32dbf7e81..91eff1ce2646 100644 --- a/src/discord/monitor/message-handler.preflight.types.ts +++ b/src/discord/monitor/message-handler.preflight.types.ts @@ -84,6 +84,7 @@ export type DiscordMessagePreflightContext = { historyEntry?: HistoryEntry; threadBindings: ThreadBindingManager; + discordRestFetch?: typeof fetch; }; export type DiscordMessagePreflightParams = { @@ -106,6 +107,7 @@ export type DiscordMessagePreflightParams = { ackReactionScope: DiscordMessagePreflightContext["ackReactionScope"]; groupPolicy: DiscordMessagePreflightContext["groupPolicy"]; threadBindings: ThreadBindingManager; + discordRestFetch?: typeof fetch; data: DiscordMessageEvent; client: Client; }; diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 750eab43b74e..a7333794cbb2 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -257,6 +257,35 @@ describe("processDiscordMessage ack reactions", () => { expect(emojis).toContain(DEFAULT_EMOJIS.stallHard); expect(emojis).toContain(DEFAULT_EMOJIS.done); }); + + it("applies status reaction emoji/timing overrides from config", async () => { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onReasoningStream?.(); + return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; + }); + + const ctx = await createBaseContext({ + cfg: { + messages: { + ackReaction: "👀", + statusReactions: { + emojis: { queued: "🟦", thinking: "🧪", done: "🏁" }, + timing: { debounceMs: 0 }, + }, + }, + session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, + }, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + const emojis = ( + sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]> + ).map((call) => call[2]); + expect(emojis).toContain("🟦"); + expect(emojis).toContain("🏁"); + }); }); describe("processDiscordMessage session routing", () => { diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 4dd357d656f7..59b0ceaf6494 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -101,10 +101,15 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) threadBindings, route, commandAuthorized, + discordRestFetch, } = ctx; - const mediaList = await resolveMediaList(message, mediaMaxBytes); - const forwardedMediaList = await resolveForwardedMediaList(message, mediaMaxBytes); + const mediaList = await resolveMediaList(message, mediaMaxBytes, discordRestFetch); + const forwardedMediaList = await resolveForwardedMediaList( + message, + mediaMaxBytes, + discordRestFetch, + ); mediaList.push(...forwardedMediaList); const text = messageText; if (!text) { @@ -147,6 +152,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) enabled: statusReactionsEnabled, adapter: discordAdapter, initialEmoji: ackReaction, + emojis: cfg.messages?.statusReactions?.emojis, + timing: cfg.messages?.statusReactions?.timing, onError: (err) => { logAckFailure({ log: logVerbose, diff --git a/src/discord/monitor/message-utils.test.ts b/src/discord/monitor/message-utils.test.ts index 4c671ce01e25..de8976ce5d23 100644 --- a/src/discord/monitor/message-utils.test.ts +++ b/src/discord/monitor/message-utils.test.ts @@ -93,6 +93,7 @@ describe("resolveForwardedMediaList", () => { url: attachment.url, filePathHint: attachment.filename, maxBytes: 512, + fetchImpl: undefined, }); expect(saveMediaBuffer).toHaveBeenCalledTimes(1); expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512); @@ -105,6 +106,38 @@ describe("resolveForwardedMediaList", () => { ]); }); + it("forwards fetchImpl to forwarded attachment downloads", async () => { + const proxyFetch = vi.fn() as unknown as typeof fetch; + const attachment = { + id: "att-proxy", + url: "https://cdn.discordapp.com/attachments/1/proxy.png", + filename: "proxy.png", + content_type: "image/png", + }; + fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("image"), + contentType: "image/png", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/proxy.png", + contentType: "image/png", + }); + + await resolveForwardedMediaList( + asMessage({ + rawData: { + message_snapshots: [{ message: { attachments: [attachment] } }], + }, + }), + 512, + proxyFetch, + ); + + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ fetchImpl: proxyFetch }), + ); + }); + it("downloads forwarded stickers", async () => { const sticker = { id: "sticker-1", @@ -134,6 +167,7 @@ describe("resolveForwardedMediaList", () => { url: "https://media.discordapp.net/stickers/sticker-1.png", filePathHint: "wave.png", maxBytes: 512, + fetchImpl: undefined, }); expect(saveMediaBuffer).toHaveBeenCalledTimes(1); expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512); @@ -201,6 +235,7 @@ describe("resolveMediaList", () => { url: "https://media.discordapp.net/stickers/sticker-2.png", filePathHint: "hello.png", maxBytes: 512, + fetchImpl: undefined, }); expect(saveMediaBuffer).toHaveBeenCalledTimes(1); expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512); @@ -212,6 +247,35 @@ describe("resolveMediaList", () => { }, ]); }); + + it("forwards fetchImpl to sticker downloads", async () => { + const proxyFetch = vi.fn() as unknown as typeof fetch; + const sticker = { + id: "sticker-proxy", + name: "proxy-sticker", + format_type: StickerFormatType.PNG, + }; + fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("sticker"), + contentType: "image/png", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/sticker-proxy.png", + contentType: "image/png", + }); + + await resolveMediaList( + asMessage({ + stickers: [sticker], + }), + 512, + proxyFetch, + ); + + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ fetchImpl: proxyFetch }), + ); + }); }); describe("resolveDiscordMessageText", () => { diff --git a/src/discord/monitor/message-utils.ts b/src/discord/monitor/message-utils.ts index 05aeab5dc768..3c523d277eff 100644 --- a/src/discord/monitor/message-utils.ts +++ b/src/discord/monitor/message-utils.ts @@ -2,7 +2,7 @@ import type { ChannelType, Client, Message } from "@buape/carbon"; import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10"; import { buildMediaPayload } from "../../channels/plugins/media-payload.js"; import { logVerbose } from "../../globals.js"; -import { fetchRemoteMedia } from "../../media/fetch.js"; +import { fetchRemoteMedia, type FetchLike } from "../../media/fetch.js"; import { saveMediaBuffer } from "../../media/store.js"; export type DiscordMediaInfo = { @@ -161,6 +161,7 @@ export function hasDiscordMessageStickers(message: Message): boolean { export async function resolveMediaList( message: Message, maxBytes: number, + fetchImpl?: FetchLike, ): Promise { const out: DiscordMediaInfo[] = []; await appendResolvedMediaFromAttachments({ @@ -168,12 +169,14 @@ export async function resolveMediaList( maxBytes, out, errorPrefix: "discord: failed to download attachment", + fetchImpl, }); await appendResolvedMediaFromStickers({ stickers: resolveDiscordMessageStickers(message), maxBytes, out, errorPrefix: "discord: failed to download sticker", + fetchImpl, }); return out; } @@ -181,6 +184,7 @@ export async function resolveMediaList( export async function resolveForwardedMediaList( message: Message, maxBytes: number, + fetchImpl?: FetchLike, ): Promise { const snapshots = resolveDiscordMessageSnapshots(message); if (snapshots.length === 0) { @@ -193,12 +197,14 @@ export async function resolveForwardedMediaList( maxBytes, out, errorPrefix: "discord: failed to download forwarded attachment", + fetchImpl, }); await appendResolvedMediaFromStickers({ stickers: snapshot.message ? resolveDiscordSnapshotStickers(snapshot.message) : [], maxBytes, out, errorPrefix: "discord: failed to download forwarded sticker", + fetchImpl, }); } return out; @@ -209,6 +215,7 @@ async function appendResolvedMediaFromAttachments(params: { maxBytes: number; out: DiscordMediaInfo[]; errorPrefix: string; + fetchImpl?: FetchLike; }) { const attachments = params.attachments; if (!attachments || attachments.length === 0) { @@ -220,6 +227,7 @@ async function appendResolvedMediaFromAttachments(params: { url: attachment.url, filePathHint: attachment.filename ?? attachment.url, maxBytes: params.maxBytes, + fetchImpl: params.fetchImpl, }); const saved = await saveMediaBuffer( fetched.buffer, @@ -296,6 +304,7 @@ async function appendResolvedMediaFromStickers(params: { maxBytes: number; out: DiscordMediaInfo[]; errorPrefix: string; + fetchImpl?: FetchLike; }) { const stickers = params.stickers; if (!stickers || stickers.length === 0) { @@ -310,6 +319,7 @@ async function appendResolvedMediaFromStickers(params: { url: candidate.url, filePathHint: candidate.fileName, maxBytes: params.maxBytes, + fetchImpl: params.fetchImpl, }); const saved = await saveMediaBuffer( fetched.buffer, diff --git a/src/discord/monitor/model-picker.test.ts b/src/discord/monitor/model-picker.test.ts index 0ef048408bb1..29365fb784b0 100644 --- a/src/discord/monitor/model-picker.test.ts +++ b/src/discord/monitor/model-picker.test.ts @@ -117,6 +117,28 @@ describe("Discord model picker custom_id", () => { }); }); + it("parses compact custom_id aliases", () => { + const parsed = parseDiscordModelPickerData({ + c: "models", + a: "submit", + v: "models", + u: "42", + p: "openai", + g: "3", + mi: "2", + }); + + expect(parsed).toEqual({ + command: "models", + action: "submit", + view: "models", + userId: "42", + provider: "openai", + page: 3, + modelIndex: 2, + }); + }); + it("parses optional submit model index", () => { const parsed = parseDiscordModelPickerData({ cmd: "models", @@ -179,6 +201,21 @@ describe("Discord model picker custom_id", () => { }), ).toThrow(/custom_id exceeds/i); }); + + it("keeps typical submit ids under Discord max length", () => { + const customId = buildDiscordModelPickerCustomId({ + command: "models", + action: "submit", + view: "models", + provider: "azure-openai-responses", + page: 1, + providerPage: 1, + modelIndex: 10, + userId: "12345678901234567890", + }); + + expect(customId.length).toBeLessThanOrEqual(DISCORD_CUSTOM_ID_MAX_CHARS); + }); }); describe("provider paging", () => { @@ -325,7 +362,7 @@ describe("Discord model picker rendering", () => { return parsed?.action === "provider"; }); expect(providerButtons).toHaveLength(Object.keys(entries).length); - expect(allButtons.some((component) => (component.custom_id ?? "").includes(":act=nav:"))).toBe( + expect(allButtons.some((component) => (component.custom_id ?? "").includes(";a=nav;"))).toBe( false, ); }); @@ -352,7 +389,7 @@ describe("Discord model picker rendering", () => { expect(rows.length).toBeGreaterThan(0); const allButtons = rows.flatMap((row) => row.components ?? []); - expect(allButtons.some((component) => (component.custom_id ?? "").includes(":act=nav:"))).toBe( + expect(allButtons.some((component) => (component.custom_id ?? "").includes(";a=nav;"))).toBe( false, ); }); diff --git a/src/discord/monitor/model-picker.ts b/src/discord/monitor/model-picker.ts index ad3654ae81b2..5c686face275 100644 --- a/src/discord/monitor/model-picker.ts +++ b/src/discord/monitor/model-picker.ts @@ -577,11 +577,11 @@ export function buildDiscordModelPickerCustomId(params: { : undefined; const parts = [ - `${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:cmd=${encodeCustomIdValue(params.command)}`, - `act=${encodeCustomIdValue(params.action)}`, - `view=${encodeCustomIdValue(params.view)}`, + `${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:c=${encodeCustomIdValue(params.command)}`, + `a=${encodeCustomIdValue(params.action)}`, + `v=${encodeCustomIdValue(params.view)}`, `u=${encodeCustomIdValue(userId)}`, - `pg=${String(page)}`, + `g=${String(page)}`, ]; if (normalizedProvider) { parts.push(`p=${encodeCustomIdValue(normalizedProvider)}`); @@ -635,12 +635,12 @@ export function parseDiscordModelPickerData(data: ComponentData): DiscordModelPi return null; } - const command = decodeCustomIdValue(coerceString(data.cmd)); - const action = decodeCustomIdValue(coerceString(data.act)); - const view = decodeCustomIdValue(coerceString(data.view)); + const command = decodeCustomIdValue(coerceString(data.c ?? data.cmd)); + const action = decodeCustomIdValue(coerceString(data.a ?? data.act)); + const view = decodeCustomIdValue(coerceString(data.v ?? data.view)); const userId = decodeCustomIdValue(coerceString(data.u)); const providerRaw = decodeCustomIdValue(coerceString(data.p)); - const page = parseRawPage(data.pg); + const page = parseRawPage(data.g ?? data.pg); const providerPage = parseRawPositiveInt(data.pp); const modelIndex = parseRawPositiveInt(data.mi); const recentSlot = parseRawPositiveInt(data.rs); diff --git a/src/discord/monitor/provider.proxy.test.ts b/src/discord/monitor/provider.proxy.test.ts index c703c8568987..4d43469e2e41 100644 --- a/src/discord/monitor/provider.proxy.test.ts +++ b/src/discord/monitor/provider.proxy.test.ts @@ -2,14 +2,22 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const { GatewayIntents, + baseRegisterClientSpy, GatewayPlugin, HttpsProxyAgent, getLastAgent, - proxyAgentSpy, + restProxyAgentSpy, + undiciFetchMock, + undiciProxyAgentSpy, resetLastAgent, webSocketSpy, + wsProxyAgentSpy, } = vi.hoisted(() => { - const proxyAgentSpy = vi.fn(); + const wsProxyAgentSpy = vi.fn(); + const undiciProxyAgentSpy = vi.fn(); + const restProxyAgentSpy = vi.fn(); + const undiciFetchMock = vi.fn(); + const baseRegisterClientSpy = vi.fn(); const webSocketSpy = vi.fn(); const GatewayIntents = { @@ -23,7 +31,17 @@ const { GuildMembers: 1 << 7, } as const; - class GatewayPlugin {} + class GatewayPlugin { + options: unknown; + gatewayInfo: unknown; + constructor(options?: unknown, gatewayInfo?: unknown) { + this.options = options; + this.gatewayInfo = gatewayInfo; + } + async registerClient(client: unknown) { + baseRegisterClientSpy(client); + } + } class HttpsProxyAgent { static lastCreated: HttpsProxyAgent | undefined; @@ -34,20 +52,24 @@ const { } this.proxyUrl = proxyUrl; HttpsProxyAgent.lastCreated = this; - proxyAgentSpy(proxyUrl); + wsProxyAgentSpy(proxyUrl); } } return { + baseRegisterClientSpy, GatewayIntents, GatewayPlugin, HttpsProxyAgent, getLastAgent: () => HttpsProxyAgent.lastCreated, - proxyAgentSpy, + restProxyAgentSpy, + undiciFetchMock, + undiciProxyAgentSpy, resetLastAgent: () => { HttpsProxyAgent.lastCreated = undefined; }, webSocketSpy, + wsProxyAgentSpy, }; }); @@ -61,6 +83,18 @@ vi.mock("https-proxy-agent", () => ({ HttpsProxyAgent, })); +vi.mock("undici", () => ({ + ProxyAgent: class { + proxyUrl: string; + constructor(proxyUrl: string) { + this.proxyUrl = proxyUrl; + undiciProxyAgentSpy(proxyUrl); + restProxyAgentSpy(proxyUrl); + } + }, + fetch: undiciFetchMock, +})); + vi.mock("ws", () => ({ default: class MockWebSocket { constructor(url: string, options?: { agent?: unknown }) { @@ -87,7 +121,11 @@ describe("createDiscordGatewayPlugin", () => { } beforeEach(() => { - proxyAgentSpy.mockClear(); + baseRegisterClientSpy.mockClear(); + restProxyAgentSpy.mockClear(); + undiciFetchMock.mockClear(); + undiciProxyAgentSpy.mockClear(); + wsProxyAgentSpy.mockClear(); webSocketSpy.mockClear(); resetLastAgent(); }); @@ -106,7 +144,7 @@ describe("createDiscordGatewayPlugin", () => { .createWebSocket; createWebSocket("wss://gateway.discord.gg"); - expect(proxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080"); + expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080"); expect(webSocketSpy).toHaveBeenCalledWith( "wss://gateway.discord.gg", expect.objectContaining({ agent: getLastAgent() }), @@ -127,4 +165,33 @@ describe("createDiscordGatewayPlugin", () => { expect(runtime.error).toHaveBeenCalled(); expect(runtime.log).not.toHaveBeenCalled(); }); + + it("uses proxy fetch for gateway metadata lookup before registering", async () => { + const runtime = createRuntime(); + undiciFetchMock.mockResolvedValue({ + json: async () => ({ url: "wss://gateway.discord.gg" }), + } as Response); + const plugin = createDiscordGatewayPlugin({ + discordConfig: { proxy: "http://proxy.test:8080" }, + runtime, + }); + + await ( + plugin as unknown as { + registerClient: (client: { options: { token: string } }) => Promise; + } + ).registerClient({ + options: { token: "token-123" }, + }); + + expect(restProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080"); + expect(undiciFetchMock).toHaveBeenCalledWith( + "https://discord.com/api/v10/gateway/bot", + expect.objectContaining({ + headers: { Authorization: "Bot token-123" }, + dispatcher: expect.objectContaining({ proxyUrl: "http://proxy.test:8080" }), + }), + ); + expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index b31697189de0..15c8e2aa7b4a 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -550,6 +550,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { allowFrom, guildEntries, threadBindings, + discordRestFetch, }); registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger)); From e11e510f5bd712b2744e907d568920139dca1423 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:06:07 +0000 Subject: [PATCH 292/408] docs(changelog): add reporter credit for exec companion hardening --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f64fc5f29d5e..5af25c30e43c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ Docs: https://docs.openclaw.ai - Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so `dmPolicy: "allowlist"` with empty `allowedUserIds` rejects all senders instead of allowing unauthorized dispatch. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. This ships in the next npm release. +- Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. This ships in the next npm release. Thanks @tdjackey for reporting. - Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. - Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. From 236b22b6a2c5669241424676cb390b296eea5cac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:09:57 +0000 Subject: [PATCH 293/408] fix(macos): guard voice audio paths with no input device (#25817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Stefan Förster <103369858+sfo2001@users.noreply.github.com> --- CHANGELOG.md | 1 + .../OpenClaw/AudioInputDeviceObserver.swift | 9 ++++++++ .../Sources/OpenClaw/MicLevelMonitor.swift | 7 +++++++ .../Sources/OpenClaw/TalkModeRuntime.swift | 6 ++++++ .../Sources/OpenClaw/VoicePushToTalk.swift | 8 +++++++ .../Sources/OpenClaw/VoiceWakeRuntime.swift | 8 +++++++ .../Sources/OpenClaw/VoiceWakeTester.swift | 8 +++++++ .../AudioInputDeviceObserverTests.swift | 21 +++++++++++++++++++ 8 files changed, 68 insertions(+) create mode 100644 apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af25c30e43c..92ce72e694cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001. - Gateway/Security: enforce gateway auth for the exact `/api/channels` plugin root path (plus `/api/channels/` descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3. - Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (`LD_*`, `DYLD_*`, `SSLKEYLOGFILE`, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3. - Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width `HOOK:...`) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3. diff --git a/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift b/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift index abbddb245887..6c01628144b0 100644 --- a/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift +++ b/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift @@ -53,6 +53,15 @@ final class AudioInputDeviceObserver { return output } + /// Returns true when the system default input device exists and is alive with input channels. + /// Use this preflight before accessing `AVAudioEngine.inputNode` to avoid SIGABRT on Macs + /// without a built-in microphone (Mac mini, Mac Pro, Mac Studio) or when an external mic + /// is disconnected. + static func hasUsableDefaultInputDevice() -> Bool { + guard let uid = self.defaultInputDeviceUID() else { return false } + return self.aliveInputDeviceUIDs().contains(uid) + } + static func defaultInputDeviceSummary() -> String { let systemObject = AudioObjectID(kAudioObjectSystemObject) var address = AudioObjectPropertyAddress( diff --git a/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift b/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift index e35057d28cfa..81e06abda2df 100644 --- a/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift +++ b/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift @@ -14,6 +14,13 @@ actor MicLevelMonitor { if self.running { return } self.logger.info( "mic level monitor start (\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public))") + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.engine = nil + throw NSError( + domain: "MicLevelMonitor", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } let engine = AVAudioEngine() self.engine = engine let input = engine.inputNode diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index 443bc192295a..70184ce9cc71 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -185,6 +185,12 @@ actor TalkModeRuntime { } guard let audioEngine = self.audioEngine else { return } + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + self.logger.error("talk mode: no usable audio input device") + return + } + let input = audioEngine.inputNode let format = input.outputFormat(forBus: 0) input.removeTap(onBus: 0) diff --git a/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift b/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift index e535ebd6616f..6eaa45e06759 100644 --- a/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift +++ b/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift @@ -244,6 +244,14 @@ actor VoicePushToTalk { } guard let audioEngine = self.audioEngine else { return } + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + throw NSError( + domain: "VoicePushToTalk", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } + let input = audioEngine.inputNode let format = input.outputFormat(forBus: 0) if self.tapInstalled { diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift b/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift index 61f913b9da88..b7e2d329b820 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift @@ -166,6 +166,14 @@ actor VoiceWakeRuntime { } guard let audioEngine = self.audioEngine else { return } + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + throw NSError( + domain: "VoiceWakeRuntime", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } + let input = audioEngine.inputNode let format = input.outputFormat(forBus: 0) guard format.channelCount > 0, format.sampleRate > 0 else { diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift b/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift index b3d0c58d90c7..063fea826ab6 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift @@ -89,6 +89,14 @@ final class VoiceWakeTester { self.logInputSelection(preferredMicID: micID) self.configureSession(preferredMicID: micID) + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + throw NSError( + domain: "VoiceWakeTester", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } + let engine = AVAudioEngine() self.audioEngine = engine diff --git a/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift b/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift new file mode 100644 index 000000000000..a175e5e1a0ac --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct AudioInputDeviceObserverTests { + @Test func hasUsableDefaultInputDeviceReturnsBool() { + // Smoke test: verifies the composition logic runs without crashing. + // Actual result depends on whether the host has an audio input device. + let result = AudioInputDeviceObserver.hasUsableDefaultInputDevice() + _ = result // suppress unused-variable warning; the assertion is "no crash" + } + + @Test func hasUsableDefaultInputDeviceConsistentWithComponents() { + // When no default UID exists, the method must return false. + // When a default UID exists, the result must match alive-set membership. + let uid = AudioInputDeviceObserver.defaultInputDeviceUID() + let alive = AudioInputDeviceObserver.aliveInputDeviceUIDs() + let expected = uid.map { alive.contains($0) } ?? false + #expect(AudioInputDeviceObserver.hasUsableDefaultInputDevice() == expected) + } +} From 31e6d185381d4baf300a262983b5c728db736542 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:11:53 +0000 Subject: [PATCH 294/408] fix(macos): prefer openclaw binary while keeping pnpm fallback (#25512) Co-authored-by: Peter Machona <7957943+chilu18@users.noreply.github.com> --- CHANGELOG.md | 1 + .../Sources/OpenClaw/CommandResolver.swift | 32 +++++++----- .../CommandResolverTests.swift | 51 ++++++++++++++++++- 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ce72e694cd..1af677a85364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- macOS/Gateway launch: prefer an available `openclaw` binary before pnpm/node runtime fallback when resolving local gateway commands, so local startup no longer fails on hosts with broken runtime discovery. (#25512) Thanks @chilu18. - macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001. - Gateway/Security: enforce gateway auth for the exact `/api/channels` plugin root path (plus `/api/channels/` descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3. - Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (`LD_*`, `DYLD_*`, `SSLKEYLOGFILE`, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3. diff --git a/apps/macos/Sources/OpenClaw/CommandResolver.swift b/apps/macos/Sources/OpenClaw/CommandResolver.swift index c17f64e30e73..cacfac2f0684 100644 --- a/apps/macos/Sources/OpenClaw/CommandResolver.swift +++ b/apps/macos/Sources/OpenClaw/CommandResolver.swift @@ -246,15 +246,17 @@ enum CommandResolver { return ssh } - let runtimeResult = self.runtimeResolution(searchPaths: searchPaths) + let root = self.projectRoot() + if let openclawPath = self.projectOpenClawExecutable(projectRoot: root) { + return [openclawPath, subcommand] + extraArgs + } + if let openclawPath = self.openclawExecutable(searchPaths: searchPaths) { + return [openclawPath, subcommand] + extraArgs + } + let runtimeResult = self.runtimeResolution(searchPaths: searchPaths) switch runtimeResult { case let .success(runtime): - let root = self.projectRoot() - if let openclawPath = self.projectOpenClawExecutable(projectRoot: root) { - return [openclawPath, subcommand] + extraArgs - } - if let entry = self.gatewayEntrypoint(in: root) { return self.makeRuntimeCommand( runtime: runtime, @@ -262,19 +264,21 @@ enum CommandResolver { subcommand: subcommand, extraArgs: extraArgs) } - if let pnpm = self.findExecutable(named: "pnpm", searchPaths: searchPaths) { - // Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs. - return [pnpm, "--silent", "openclaw", subcommand] + extraArgs - } - if let openclawPath = self.openclawExecutable(searchPaths: searchPaths) { - return [openclawPath, subcommand] + extraArgs - } + case .failure: + break + } + if let pnpm = self.findExecutable(named: "pnpm", searchPaths: searchPaths) { + // Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs. + return [pnpm, "--silent", "openclaw", subcommand] + extraArgs + } + + switch runtimeResult { + case .success: let missingEntry = """ openclaw entrypoint missing (looked for dist/index.js or openclaw.mjs); run pnpm build. """ return self.errorCommand(with: missingEntry) - case let .failure(error): return self.runtimeErrorCommand(error) } diff --git a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift index 7a71bc08b6ea..d84706791838 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift @@ -66,6 +66,48 @@ import Testing } } + @Test func prefersOpenClawBinaryOverPnpm() async throws { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + + let tmp = try makeTempDir() + CommandResolver.setProjectRoot(tmp.path) + + let binDir = tmp.appendingPathComponent("bin") + let openclawPath = binDir.appendingPathComponent("openclaw") + let pnpmPath = binDir.appendingPathComponent("pnpm") + try self.makeExec(at: openclawPath) + try self.makeExec(at: pnpmPath) + + let cmd = CommandResolver.openclawCommand( + subcommand: "rpc", + defaults: defaults, + configRoot: [:], + searchPaths: [binDir.path]) + + #expect(cmd.prefix(2).elementsEqual([openclawPath.path, "rpc"])) + } + + @Test func usesOpenClawBinaryWithoutNodeRuntime() async throws { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + + let tmp = try makeTempDir() + CommandResolver.setProjectRoot(tmp.path) + + let binDir = tmp.appendingPathComponent("bin") + let openclawPath = binDir.appendingPathComponent("openclaw") + try self.makeExec(at: openclawPath) + + let cmd = CommandResolver.openclawCommand( + subcommand: "gateway", + defaults: defaults, + configRoot: [:], + searchPaths: [binDir.path]) + + #expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"])) + } + @Test func fallsBackToPnpm() async throws { let defaults = self.makeDefaults() defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) @@ -76,7 +118,11 @@ import Testing let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm") try self.makeExec(at: pnpmPath) - let cmd = CommandResolver.openclawCommand(subcommand: "rpc", defaults: defaults, configRoot: [:]) + let cmd = CommandResolver.openclawCommand( + subcommand: "rpc", + defaults: defaults, + configRoot: [:], + searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path]) #expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "openclaw", "rpc"])) } @@ -95,7 +141,8 @@ import Testing subcommand: "health", extraArgs: ["--json", "--timeout", "5"], defaults: defaults, - configRoot: [:]) + configRoot: [:], + searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path]) #expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "openclaw", "health", "--json"])) #expect(cmd.suffix(2).elementsEqual(["--timeout", "5"])) From daa4f34ce84f5699a1787b03b29760a6e62c3229 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 18:45:29 -0500 Subject: [PATCH 295/408] Auth: bypass cooldown tracking for OpenRouter --- src/agents/auth-profiles/usage.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/agents/auth-profiles/usage.ts b/src/agents/auth-profiles/usage.ts index cc25aabdf670..958e3ae127e4 100644 --- a/src/agents/auth-profiles/usage.ts +++ b/src/agents/auth-profiles/usage.ts @@ -17,6 +17,10 @@ const FAILURE_REASON_ORDER = new Map( FAILURE_REASON_PRIORITY.map((reason, index) => [reason, index]), ); +function isAuthCooldownBypassedForProvider(provider: string | undefined): boolean { + return normalizeProviderId(provider ?? "") === "openrouter"; +} + export function resolveProfileUnusableUntil( stats: Pick, ): number | null { @@ -33,6 +37,9 @@ export function resolveProfileUnusableUntil( * Check if a profile is currently in cooldown (due to rate limiting or errors). */ export function isProfileInCooldown(store: AuthProfileStore, profileId: string): boolean { + if (isAuthCooldownBypassedForProvider(store.profiles[profileId]?.provider)) { + return false; + } const stats = store.usageStats?.[profileId]; if (!stats) { return false; @@ -342,6 +349,9 @@ export function resolveProfileUnusableUntilForDisplay( store: AuthProfileStore, profileId: string, ): number | null { + if (isAuthCooldownBypassedForProvider(store.profiles[profileId]?.provider)) { + return null; + } const stats = store.usageStats?.[profileId]; if (!stats) { return null; @@ -425,11 +435,15 @@ export async function markAuthProfileFailure(params: { agentDir?: string; }): Promise { const { store, profileId, reason, agentDir, cfg } = params; + const profile = store.profiles[profileId]; + if (!profile || isAuthCooldownBypassedForProvider(profile.provider)) { + return; + } const updated = await updateAuthProfileStoreWithLock({ agentDir, updater: (freshStore) => { const profile = freshStore.profiles[profileId]; - if (!profile) { + if (!profile || isAuthCooldownBypassedForProvider(profile.provider)) { return false; } freshStore.usageStats = freshStore.usageStats ?? {}; From f1d5c1a31f3ef8ca2d333ba030cc25827d8f5e88 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 18:45:38 -0500 Subject: [PATCH 296/408] Auth: use cooldown helper in explicit profile order --- src/agents/auth-profiles/order.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/agents/auth-profiles/order.ts b/src/agents/auth-profiles/order.ts index 045a171fe8be..e95bb9f68ec7 100644 --- a/src/agents/auth-profiles/order.ts +++ b/src/agents/auth-profiles/order.ts @@ -102,13 +102,9 @@ export function resolveAuthProfileOrder(params: { const inCooldown: Array<{ profileId: string; cooldownUntil: number }> = []; for (const profileId of deduped) { - const cooldownUntil = resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? 0; - if ( - typeof cooldownUntil === "number" && - Number.isFinite(cooldownUntil) && - cooldownUntil > 0 && - now < cooldownUntil - ) { + if (isProfileInCooldown(store, profileId)) { + const cooldownUntil = + resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? now; inCooldown.push({ profileId, cooldownUntil }); } else { available.push(profileId); From 5de04960a0c293b1642b961dddfaec0ebcfa0022 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 18:45:45 -0500 Subject: [PATCH 297/408] Tests: cover OpenRouter cooldown display bypass --- src/agents/auth-profiles/usage.test.ts | 36 ++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/agents/auth-profiles/usage.test.ts b/src/agents/auth-profiles/usage.test.ts index 3d7c2305d3f6..0025007f7290 100644 --- a/src/agents/auth-profiles/usage.test.ts +++ b/src/agents/auth-profiles/usage.test.ts @@ -7,6 +7,7 @@ import { markAuthProfileFailure, resolveProfilesUnavailableReason, resolveProfileUnusableUntil, + resolveProfileUnusableUntilForDisplay, } from "./usage.js"; vi.mock("./store.js", async (importOriginal) => { @@ -24,6 +25,7 @@ function makeStore(usageStats: AuthProfileStore["usageStats"]): AuthProfileStore profiles: { "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-test" }, "openai:default": { type: "api_key", provider: "openai", key: "sk-test-2" }, + "openrouter:default": { type: "api_key", provider: "openrouter", key: "sk-or-test" }, }, usageStats, }; @@ -51,6 +53,29 @@ describe("resolveProfileUnusableUntil", () => { }); }); +describe("resolveProfileUnusableUntilForDisplay", () => { + it("hides cooldown markers for OpenRouter profiles", () => { + const store = makeStore({ + "openrouter:default": { + cooldownUntil: Date.now() + 60_000, + }, + }); + + expect(resolveProfileUnusableUntilForDisplay(store, "openrouter:default")).toBeNull(); + }); + + it("keeps cooldown markers visible for other providers", () => { + const until = Date.now() + 60_000; + const store = makeStore({ + "anthropic:default": { + cooldownUntil: until, + }, + }); + + expect(resolveProfileUnusableUntilForDisplay(store, "anthropic:default")).toBe(until); + }); +}); + // --------------------------------------------------------------------------- // isProfileInCooldown // --------------------------------------------------------------------------- @@ -84,6 +109,17 @@ describe("isProfileInCooldown", () => { }); expect(isProfileInCooldown(store, "anthropic:default")).toBe(true); }); + + it("returns false for OpenRouter even when cooldown fields exist", () => { + const store = makeStore({ + "openrouter:default": { + cooldownUntil: Date.now() + 60_000, + disabledUntil: Date.now() + 60_000, + disabledReason: "billing", + }, + }); + expect(isProfileInCooldown(store, "openrouter:default")).toBe(false); + }); }); describe("resolveProfilesUnavailableReason", () => { From ebc8c4b6091648069c2172dd1527b8c580574428 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 18:45:53 -0500 Subject: [PATCH 298/408] Tests: skip OpenRouter failure cooldown persistence --- ...th-profiles.markauthprofilefailure.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/agents/auth-profiles.markauthprofilefailure.test.ts b/src/agents/auth-profiles.markauthprofilefailure.test.ts index c2720a7edde3..1a30d8a91199 100644 --- a/src/agents/auth-profiles.markauthprofilefailure.test.ts +++ b/src/agents/auth-profiles.markauthprofilefailure.test.ts @@ -26,6 +26,11 @@ async function withAuthProfileStore( provider: "anthropic", key: "sk-default", }, + "openrouter:default": { + type: "api_key", + provider: "openrouter", + key: "sk-or-default", + }, }, }), ); @@ -152,6 +157,29 @@ describe("markAuthProfileFailure", () => { fs.rmSync(agentDir, { recursive: true, force: true }); } }); + + it("does not persist cooldown windows for OpenRouter profiles", async () => { + await withAuthProfileStore(async ({ agentDir, store }) => { + await markAuthProfileFailure({ + store, + profileId: "openrouter:default", + reason: "rate_limit", + agentDir, + }); + + await markAuthProfileFailure({ + store, + profileId: "openrouter:default", + reason: "billing", + agentDir, + }); + + expect(store.usageStats?.["openrouter:default"]).toBeUndefined(); + + const reloaded = ensureAuthProfileStore(agentDir); + expect(reloaded.usageStats?.["openrouter:default"]).toBeUndefined(); + }); + }); }); describe("calculateAuthProfileCooldownMs", () => { From 06f0b4a193a256e46a6e3cb47bc79baac9cfa720 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 18:45:57 -0500 Subject: [PATCH 299/408] Tests: keep OpenRouter runnable with legacy cooldown markers --- src/agents/model-fallback.test.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index bb5704be1a75..6b5128d90eaf 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -373,6 +373,37 @@ describe("runWithModelFallback", () => { }); }); + it("does not skip OpenRouter when legacy cooldown markers exist", async () => { + const provider = "openrouter"; + const cfg = makeProviderFallbackCfg(provider); + const store = makeSingleProviderStore({ + provider, + usageStat: { + cooldownUntil: Date.now() + 5 * 60_000, + disabledUntil: Date.now() + 10 * 60_000, + disabledReason: "billing", + }, + }); + const run = vi.fn().mockImplementation(async (providerId) => { + if (providerId === "openrouter") { + return "ok"; + } + throw new Error(`unexpected provider: ${providerId}`); + }); + + const result = await runWithStoredAuth({ + cfg, + store, + provider, + run, + }); + + expect(result.result).toBe("ok"); + expect(run).toHaveBeenCalledTimes(1); + expect(run.mock.calls[0]?.[0]).toBe("openrouter"); + expect(result.attempts).toEqual([]); + }); + it("propagates disabled reason when all profiles are unavailable", async () => { const now = Date.now(); await expectSkippedUnavailableProvider({ From aee38c42d30661c812d366ff3ec0f88b3ff44b24 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 18:46:04 -0500 Subject: [PATCH 300/408] Tests: preserve OpenRouter explicit auth order under cooldown fields --- ...tize-lastgood-round-robin-ordering.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts index a13ce8fd06d5..3e6437d7d27b 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts @@ -118,6 +118,50 @@ describe("resolveAuthProfileOrder", () => { }, ); + it.each(["store", "config"] as const)( + "keeps OpenRouter explicit order even when cooldown fields exist (%s)", + (orderSource) => { + const now = Date.now(); + const explicitOrder = ["openrouter:default", "openrouter:work"]; + const order = resolveAuthProfileOrder({ + cfg: + orderSource === "config" + ? { + auth: { + order: { openrouter: explicitOrder }, + }, + } + : undefined, + store: { + version: 1, + ...(orderSource === "store" ? { order: { openrouter: explicitOrder } } : {}), + profiles: { + "openrouter:default": { + type: "api_key", + provider: "openrouter", + key: "sk-or-default", + }, + "openrouter:work": { + type: "api_key", + provider: "openrouter", + key: "sk-or-work", + }, + }, + usageStats: { + "openrouter:default": { + cooldownUntil: now + 60_000, + disabledUntil: now + 120_000, + disabledReason: "billing", + }, + }, + }, + provider: "openrouter", + }); + + expect(order).toEqual(explicitOrder); + }, + ); + it("mode: oauth config accepts both oauth and token credentials (issue #559)", () => { const now = Date.now(); const storeWithBothTypes: AuthProfileStore = { From 1cb14fcf1c3c1897cca474de8c15470b2e3782a4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 18:47:15 -0500 Subject: [PATCH 301/408] Changelog: note OpenRouter cooldown bypass --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1af677a85364..74a3eba730b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,7 @@ Docs: https://docs.openclaw.ai - Security/Skills: escape user-controlled prompt, filename, and output-path values in `openai-image-gen` HTML gallery generation to prevent stored XSS in generated `index.html` output. (#12538) Thanks @CornBrother0x. - Security/Skills: harden `skill-creator` packaging by skipping symlink entries and rejecting files whose resolved paths escape the selected skill root. (#24260, #16959) Thanks @CornBrother0x and @vincentkoc. - Security/OTEL: redact sensitive values (API keys, tokens, credential fields) from diagnostics-otel log bodies, log attributes, and error/reason span fields before OTLP export. (#12542) Thanks @brandonwise. +- Providers/OpenRouter/Auth profiles: bypass auth-profile cooldown/disable windows for OpenRouter, so provider failures no longer put OpenRouter profiles into local cooldown and stale legacy cooldown markers are ignored in fallback and status selection paths. (#25892) Thanks @alexanderatallah for raising this and @vincentkoc for the fix. - Security/CI: add pre-commit security hook coverage for private-key detection and production dependency auditing, and enforce those checks in CI alongside baseline secret scanning. Thanks @vincentkoc. - Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc. - Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc. From 99dd3448e86ef3534f23aff5bc8025c2e3dc8086 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 18:53:19 -0500 Subject: [PATCH 302/408] Changelog: remove unrelated session entries from PR --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74a3eba730b1..79c0398c67d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -160,8 +160,6 @@ Docs: https://docs.openclaw.ai ### Fixes -- Sessions/Resilience: ignore invalid persisted `sessionFile` metadata and fall back to the derived safe transcript path instead of aborting session resolution for handlers and tooling. (#16061) Thanks @haoyifan and @vincentkoc. -- Sessions/Paths: resolve symlinked state-dir aliases during transcript-path validation while preserving safe cross-agent/state-root compatibility for valid `agents//sessions/**` paths. (#18593) Thanks @EpaL and @vincentkoc. - Agents/Compaction: count auto-compactions only after a non-retry `auto_compaction_end`, keeping session `compactionCount` aligned to completed compactions. - Security/CLI: redact sensitive values in `openclaw config get` output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo. - Agents/Moonshot: force `supportsDeveloperRole=false` for Moonshot-compatible `openai-completions` models (provider `moonshot` and Moonshot base URLs), so initial runs no longer send unsupported `developer` roles that trigger `ROLE_UNSPECIFIED` errors. (#21060, #22194) Thanks @ShengFuC. From 30082c9af10dccb2a1341480d1e9eea94421b12b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 19:11:47 -0500 Subject: [PATCH 303/408] Update CHANGELOG.md --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c0398c67d6..95006101c251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (`LD_*`, `DYLD_*`, `SSLKEYLOGFILE`, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3. - Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width `HOOK:...`) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3. - Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3. +- Providers/OpenRouter/Auth profiles: bypass auth-profile cooldown/disable windows for OpenRouter, so provider failures no longer put OpenRouter profiles into local cooldown and stale legacy cooldown markers are ignored in fallback and status selection paths. (#25892) Thanks @alexanderatallah for raising this and @vincentkoc for the fix. - WhatsApp/Web reconnect: treat close status `440` as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson. - Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko. - Models/Bedrock auth: normalize additional Bedrock provider aliases (`bedrock`, `aws-bedrock`, `aws_bedrock`, `amazon bedrock`) to canonical `amazon-bedrock`, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13. @@ -121,7 +122,6 @@ Docs: https://docs.openclaw.ai - Security/Skills: escape user-controlled prompt, filename, and output-path values in `openai-image-gen` HTML gallery generation to prevent stored XSS in generated `index.html` output. (#12538) Thanks @CornBrother0x. - Security/Skills: harden `skill-creator` packaging by skipping symlink entries and rejecting files whose resolved paths escape the selected skill root. (#24260, #16959) Thanks @CornBrother0x and @vincentkoc. - Security/OTEL: redact sensitive values (API keys, tokens, credential fields) from diagnostics-otel log bodies, log attributes, and error/reason span fields before OTLP export. (#12542) Thanks @brandonwise. -- Providers/OpenRouter/Auth profiles: bypass auth-profile cooldown/disable windows for OpenRouter, so provider failures no longer put OpenRouter profiles into local cooldown and stale legacy cooldown markers are ignored in fallback and status selection paths. (#25892) Thanks @alexanderatallah for raising this and @vincentkoc for the fix. - Security/CI: add pre-commit security hook coverage for private-key detection and production dependency auditing, and enforce those checks in CI alongside baseline secret scanning. Thanks @vincentkoc. - Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc. - Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc. @@ -160,6 +160,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- Sessions/Resilience: ignore invalid persisted `sessionFile` metadata and fall back to the derived safe transcript path instead of aborting session resolution for handlers and tooling. (#16061) Thanks @haoyifan and @vincentkoc. +- Sessions/Paths: resolve symlinked state-dir aliases during transcript-path validation while preserving safe cross-agent/state-root compatibility for valid `agents//sessions/**` paths. (#18593) Thanks @EpaL and @vincentkoc. - Agents/Compaction: count auto-compactions only after a non-retry `auto_compaction_end`, keeping session `compactionCount` aligned to completed compactions. - Security/CLI: redact sensitive values in `openclaw config get` output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo. - Agents/Moonshot: force `supportsDeveloperRole=false` for Moonshot-compatible `openai-completions` models (provider `moonshot` and Moonshot base URLs), so initial runs no longer send unsupported `developer` roles that trigger `ROLE_UNSPECIFIED` errors. (#21060, #22194) Thanks @ShengFuC. From 11a0495d5f46fc5b20154a97af1c206f7acf4cc4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:12:39 +0000 Subject: [PATCH 304/408] fix(macos): default voice wake forwarding to webchat (#25440) Co-authored-by: Peter Machona <7957943+chilu18@users.noreply.github.com> --- CHANGELOG.md | 1 + apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift | 2 +- .../macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95006101c251..53562081e001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- macOS/Voice wake routing: default forwarded voice-wake transcripts to the `webchat` channel (instead of ambiguous `last` routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18. - macOS/Gateway launch: prefer an available `openclaw` binary before pnpm/node runtime fallback when resolving local gateway commands, so local startup no longer fails on hosts with broken runtime discovery. (#25512) Thanks @chilu18. - macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001. - Gateway/Security: enforce gateway auth for the exact `/api/channels` plugin root path (plus `/api/channels/` descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3. diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift b/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift index ee634a628ed4..0c6ea54c90e0 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift @@ -37,7 +37,7 @@ enum VoiceWakeForwarder { var thinking: String = "low" var deliver: Bool = true var to: String? - var channel: GatewayAgentChannel = .last + var channel: GatewayAgentChannel = .webchat } @discardableResult diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift index 46971ac314c1..6640d526a741 100644 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift @@ -17,6 +17,7 @@ import Testing #expect(opts.thinking == "low") #expect(opts.deliver == true) #expect(opts.to == nil) - #expect(opts.channel == .last) + #expect(opts.channel == .webchat) + #expect(opts.channel.shouldDeliver(opts.deliver) == false) } } From 1970a1e9e5e70f367d59dcbde1fb35a03e0b91f9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:14:00 +0000 Subject: [PATCH 305/408] fix(macos): keep Return for IME marked text commit (#25178) Co-authored-by: jft0m <9837901+bottotl@users.noreply.github.com> --- CHANGELOG.md | 1 + apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift | 5 +++++ .../OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53562081e001..8d7fc9e06072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl. - macOS/Voice wake routing: default forwarded voice-wake transcripts to the `webchat` channel (instead of ambiguous `last` routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18. - macOS/Gateway launch: prefer an available `openclaw` binary before pnpm/node runtime fallback when resolving local gateway commands, so local startup no longer fails on hosts with broken runtime discovery. (#25512) Thanks @chilu18. - macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001. diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift index 8e88c86d45d1..bbbed72926b5 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift @@ -185,6 +185,11 @@ private final class TranscriptNSTextView: NSTextView { self.onEscape?() return } + // Keep IME candidate confirmation behavior: Return should commit marked text first. + if isReturn, self.hasMarkedText() { + super.keyDown(with: event) + return + } if isReturn, event.modifierFlags.contains(.command) { self.onSend?() return diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift index 145e17f3b7b6..627148381779 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift @@ -486,6 +486,10 @@ private final class ChatComposerNSTextView: NSTextView { override func keyDown(with event: NSEvent) { let isReturn = event.keyCode == 36 if isReturn { + if self.hasMarkedText() { + super.keyDown(with: event) + return + } if event.modifierFlags.contains(.shift) { super.insertNewline(nil) return From 57c9a18180c8b14885bbd95474cbb17ff2d03f0b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:13:33 +0000 Subject: [PATCH 306/408] fix(security): block env depth-overflow approval bypass --- CHANGELOG.md | 1 + src/infra/exec-approvals.test.ts | 30 ++++++++ src/infra/exec-wrapper-resolution.ts | 11 +++ src/node-host/invoke-system-run.test.ts | 95 +++++++++++++++++++++++++ 4 files changed, 137 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d7fc9e06072..ffc9794fc6c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. This ships in the next npm release. Thanks @tdjackey for reporting. - Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. - Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 407261f43a35..9ce901a84820 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -299,6 +299,36 @@ describe("exec approvals command resolution", () => { expect(allowlistEval.segmentSatisfiedBy).toEqual([null]); }); + it("fails closed when transparent env wrappers exceed unwrap depth", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const binDir = path.join(dir, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const envPath = path.join(binDir, "env"); + fs.writeFileSync(envPath, "#!/bin/sh\n"); + fs.chmodSync(envPath, 0o755); + + const analysis = analyzeArgvCommand({ + argv: [envPath, envPath, envPath, envPath, envPath, "/bin/sh", "-c", "echo pwned"], + cwd: dir, + env: makePathEnv(binDir), + }); + const allowlistEval = evaluateExecAllowlist({ + analysis, + allowlist: [{ pattern: envPath }], + safeBins: normalizeSafeBins([]), + cwd: dir, + }); + + expect(analysis.ok).toBe(true); + expect(analysis.segments[0]?.resolution?.policyBlocked).toBe(true); + expect(analysis.segments[0]?.resolution?.blockedWrapper).toBe("env"); + expect(allowlistEval.allowlistSatisfied).toBe(false); + expect(allowlistEval.segmentSatisfiedBy).toEqual([null]); + }); + it("unwraps env wrapper with shell inner executable", () => { const resolution = resolveCommandResolutionFromArgv(["/usr/bin/env", "bash", "-lc", "echo hi"]); expect(resolution?.rawExecutable).toBe("bash"); diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts index 55e05842e365..6d09029da05c 100644 --- a/src/infra/exec-wrapper-resolution.ts +++ b/src/infra/exec-wrapper-resolution.ts @@ -478,6 +478,17 @@ export function resolveDispatchWrapperExecutionPlan( } current = unwrap.argv; } + if (wrappers.length >= maxDepth) { + const overflow = unwrapKnownDispatchWrapperInvocation(current); + if (overflow.kind === "blocked" || overflow.kind === "unwrapped") { + return { + argv: current, + wrappers, + policyBlocked: true, + blockedWrapper: overflow.wrapper, + }; + } + } return { argv: current, wrappers, policyBlocked: false }; } diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index a3be712655da..675f108b2ee3 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -348,4 +348,99 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }), ); }); + + it("denies nested env shell payloads when wrapper depth is exceeded", async () => { + if (process.platform === "win32") { + return; + } + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-depth-overflow-")); + const previousOpenClawHome = process.env.OPENCLAW_HOME; + const marker = path.join(tempHome, "pwned.txt"); + process.env.OPENCLAW_HOME = tempHome; + saveExecApprovals({ + version: 1, + defaults: { + security: "allowlist", + ask: "on-miss", + askFallback: "deny", + }, + agents: { + main: { + allowlist: [{ pattern: "/usr/bin/env" }], + }, + }, + }); + const runCommand = vi.fn(async () => { + fs.writeFileSync(marker, "executed"); + return { + success: true, + stdout: "local-ok", + stderr: "", + timedOut: false, + truncated: false, + exitCode: 0, + error: null, + }; + }); + const sendInvokeResult = vi.fn(async () => {}); + const sendNodeEvent = vi.fn(async () => {}); + + try { + await handleSystemRunInvoke({ + client: {} as never, + params: { + command: [ + "/usr/bin/env", + "/usr/bin/env", + "/usr/bin/env", + "/usr/bin/env", + "/usr/bin/env", + "/bin/sh", + "-c", + `echo PWNED > ${marker}`, + ], + sessionKey: "agent:main:main", + }, + skillBins: { + current: async () => [], + }, + execHostEnforced: false, + execHostFallbackAllowed: true, + resolveExecSecurity: () => "allowlist", + resolveExecAsk: () => "on-miss", + isCmdExeInvocation: () => false, + sanitizeEnv: () => undefined, + runCommand, + runViaMacAppExecHost: vi.fn(async () => null), + sendNodeEvent, + buildExecEventPayload: (payload) => payload, + sendInvokeResult, + sendExecFinishedEvent: vi.fn(async () => {}), + preferMacAppExecHost: false, + }); + } finally { + if (previousOpenClawHome === undefined) { + delete process.env.OPENCLAW_HOME; + } else { + process.env.OPENCLAW_HOME = previousOpenClawHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + + expect(runCommand).not.toHaveBeenCalled(); + expect(fs.existsSync(marker)).toBe(false); + expect(sendNodeEvent).toHaveBeenCalledWith( + expect.anything(), + "exec.denied", + expect.objectContaining({ reason: "approval-required" }), + ); + expect(sendInvokeResult).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: "SYSTEM_RUN_DENIED: approval required", + }), + }), + ); + }); }); From 16b228e4a696dedb4896728eaa3c44545140c536 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:14:51 +0000 Subject: [PATCH 307/408] fix(macos): resolve webchat panel corner clipping (#22458) Co-authored-by: apethree <3081182+apethree@users.noreply.github.com> Co-authored-by: agisilaos <3073709+agisilaos@users.noreply.github.com> --- CHANGELOG.md | 1 + apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffc9794fc6c0..d1eeb27cbdb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos. - macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl. - macOS/Voice wake routing: default forwarded voice-wake transcripts to the `webchat` channel (instead of ambiguous `last` routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18. - macOS/Gateway launch: prefer an available `openclaw` binary before pnpm/node runtime fallback when resolving local gateway commands, so local startup no longer fails on hosts with broken runtime discovery. (#25512) Thanks @chilu18. diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index 5b866304b090..46e5d80a01eb 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -316,7 +316,12 @@ final class WebChatSwiftUIWindowController { let controller = NSViewController() let effectView = NSVisualEffectView() effectView.material = .sidebar - effectView.blendingMode = .behindWindow + effectView.blendingMode = switch presentation { + case .panel: + .withinWindow + case .window: + .behindWindow + } effectView.state = .active effectView.wantsLayer = true effectView.layer?.cornerCurve = .continuous @@ -328,6 +333,7 @@ final class WebChatSwiftUIWindowController { } effectView.layer?.cornerRadius = cornerRadius effectView.layer?.masksToBounds = true + effectView.layer?.backgroundColor = NSColor.clear.cgColor effectView.translatesAutoresizingMaskIntoConstraints = true effectView.autoresizingMask = [.width, .height] @@ -335,6 +341,9 @@ final class WebChatSwiftUIWindowController { hosting.view.translatesAutoresizingMaskIntoConstraints = false hosting.view.wantsLayer = true + hosting.view.layer?.cornerCurve = .continuous + hosting.view.layer?.cornerRadius = cornerRadius + hosting.view.layer?.masksToBounds = true hosting.view.layer?.backgroundColor = NSColor.clear.cgColor controller.addChild(hosting) From e9068e257167de9dc59b53ca8ff6368b6cc74419 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 19:07:51 -0500 Subject: [PATCH 308/408] Agents: trust explicit allowlist refs beyond catalog --- src/agents/model-selection.ts | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 2eb2f8e9c557..d37609af368a 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -400,22 +400,23 @@ export function buildAllowedModelSet(params: { } const allowedKeys = new Set(); - const configuredProviders = (params.cfg.models?.providers ?? {}) as Record; + const syntheticCatalogEntries = new Map(); for (const raw of rawAllowlist) { const parsed = parseModelRef(String(raw), params.defaultProvider); if (!parsed) { continue; } const key = modelKey(parsed.provider, parsed.model); - const providerKey = normalizeProviderId(parsed.provider); - if (isCliProvider(parsed.provider, params.cfg)) { - allowedKeys.add(key); - } else if (catalogKeys.has(key)) { - allowedKeys.add(key); - } else if (configuredProviders[providerKey] != null) { - // Explicitly configured providers should be allowlist-able even when - // they don't exist in the curated model catalog. - allowedKeys.add(key); + // Explicit allowlist entries are always trusted, even when bundled catalog + // data is stale and does not include the configured model yet. + allowedKeys.add(key); + + if (!catalogKeys.has(key) && !syntheticCatalogEntries.has(key)) { + syntheticCatalogEntries.set(key, { + id: parsed.model, + name: parsed.model, + provider: parsed.provider, + }); } } @@ -423,9 +424,10 @@ export function buildAllowedModelSet(params: { allowedKeys.add(defaultKey); } - const allowedCatalog = params.catalog.filter((entry) => - allowedKeys.has(modelKey(entry.provider, entry.id)), - ); + const allowedCatalog = [ + ...params.catalog.filter((entry) => allowedKeys.has(modelKey(entry.provider, entry.id))), + ...syntheticCatalogEntries.values(), + ]; if (allowedCatalog.length === 0 && allowedKeys.size === 0) { if (defaultKey) { From f34325ec01dc92c9fb5d8a884ad371584f1993d9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 19:07:56 -0500 Subject: [PATCH 309/408] Tests: cover allowlist refs missing from catalog --- src/agents/model-selection.test.ts | 73 ++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 3c2f7edf2794..78c214657732 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -2,12 +2,14 @@ import { describe, it, expect, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { resetLogger, setLoggerOverride } from "../logging/logger.js"; import { + buildAllowedModelSet, parseModelRef, - resolveModelRefFromString, - resolveConfiguredModelRef, buildModelAliasIndex, - normalizeProviderId, modelKey, + normalizeProviderId, + resolveAllowedModelRef, + resolveConfiguredModelRef, + resolveModelRefFromString, } from "./model-selection.js"; describe("model-selection", () => { @@ -159,6 +161,71 @@ describe("model-selection", () => { }); }); + describe("buildAllowedModelSet", () => { + it("keeps explicitly allowlisted models even when missing from bundled catalog", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.2" }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + } as OpenClawConfig; + + const catalog = [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }, + ]; + + const result = buildAllowedModelSet({ + cfg, + catalog, + defaultProvider: "anthropic", + }); + + expect(result.allowAny).toBe(false); + expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(true); + expect(result.allowedCatalog).toEqual([ + { provider: "anthropic", id: "claude-sonnet-4-6", name: "claude-sonnet-4-6" }, + ]); + }); + }); + + describe("resolveAllowedModelRef", () => { + it("accepts explicit allowlist refs absent from bundled catalog", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.2" }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + } as OpenClawConfig; + + const catalog = [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }, + ]; + + const result = resolveAllowedModelRef({ + cfg, + catalog, + raw: "anthropic/claude-sonnet-4-6", + defaultProvider: "openai", + defaultModel: "gpt-5.2", + }); + + expect(result).toEqual({ + key: "anthropic/claude-sonnet-4-6", + ref: { provider: "anthropic", model: "claude-sonnet-4-6" }, + }); + }); + }); + describe("resolveModelRefFromString", () => { it("should resolve from string with alias", () => { const index = { From f7cf3d0dad89abeb4320921af9d286068a4d0c88 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 19:08:04 -0500 Subject: [PATCH 310/408] Gateway tests: accept allowlisted refs absent from catalog --- src/gateway/sessions-patch.test.ts | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index 1e3d92b33df5..6bf20d326411 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -243,6 +243,38 @@ describe("gateway sessions patch", () => { expect(res.entry.modelOverride).toBe("claude-sonnet-4-6"); }); + test("accepts explicit allowlisted refs absent from bundled catalog", async () => { + const store: Record = {}; + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.2" }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + } as OpenClawConfig; + + const res = await applySessionsPatchToStore({ + cfg, + store, + storeKey: "agent:main:main", + patch: { key: "agent:main:main", model: "anthropic/claude-sonnet-4-6" }, + loadGatewayModelCatalog: async () => [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, + ], + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.entry.providerOverride).toBe("anthropic"); + expect(res.entry.modelOverride).toBe("claude-sonnet-4-6"); + }); + test("sets spawnDepth for subagent sessions", async () => { const store: Record = {}; const res = await applySessionsPatchToStore({ From 5509bf2c75d89a0f4b8e892b6e91ce4fbba9a840 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 19:08:09 -0500 Subject: [PATCH 311/408] Gateway tests: include synthetic allowlist models in models.list --- src/gateway/server.models-voicewake-misc.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index b1dda9a05cae..837a17cd3bda 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -328,7 +328,7 @@ describe("gateway server models + voicewake", () => { ); }); - test("models.list falls back to full catalog when allowlist has no catalog match", async () => { + test("models.list includes synthetic entries for allowlist models absent from catalog", async () => { await withModelsConfig( { agents: { @@ -345,7 +345,13 @@ describe("gateway server models + voicewake", () => { const res = await listModels(); expect(res.ok).toBe(true); - expect(res.payload?.models).toEqual(expectedSortedCatalog()); + expect(res.payload?.models).toEqual([ + { + id: "not-in-catalog", + name: "not-in-catalog", + provider: "openai", + }, + ]); }, ); }); From 1839ba8ccbd772ada023ed08ba334f68b20d23d0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 19:08:15 -0500 Subject: [PATCH 312/408] Changelog: note allowlist stale-catalog model selection fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1eeb27cbdb0..bb9bddd6392c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - WhatsApp/Web reconnect: treat close status `440` as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson. - Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko. - Models/Bedrock auth: normalize additional Bedrock provider aliases (`bedrock`, `aws-bedrock`, `aws_bedrock`, `amazon bedrock`) to canonical `amazon-bedrock`, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13. +- Gateway/Models: honor explicit `agents.defaults.models` allowlist refs even when bundled model catalog data is stale, synthesize missing allowlist entries in `models.list`, and allow `sessions.patch`/`/model` selection for those refs without false `model not allowed` errors. (#20291) Thanks @kensipe, @nikolasdehor, and @vincentkoc. - Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky. - Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire `messages.statusReactions.{emojis,timing}` into Discord reaction lifecycle control, and compact model-picker `custom_id` keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr. - Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin. From 9cd50c51b04742f97f46ebd82ab13be6c944066a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:19:36 +0000 Subject: [PATCH 313/408] fix(discord): harden voice DAVE receive reliability (#25861) Reimplements and consolidates related work: - #24339 stale disconnect/destroyed session guards - #25312 voice listener cleanup on stop - #23036 restore @snazzah/davey runtime dependency Adds Discord voice DAVE config passthrough, repeated decrypt failure rejoin recovery, regression tests, docs, and changelog updates. Co-authored-by: Frank Yang Co-authored-by: Do Cao Hieu --- CHANGELOG.md | 1 + docs/channels/discord.md | 4 + docs/gateway/configuration-reference.md | 3 + package.json | 1 + pnpm-lock.yaml | 151 +++++++++++++ src/config/schema.help.ts | 4 + src/config/schema.labels.ts | 2 + src/config/types.discord.ts | 4 + src/config/zod-schema.providers-core.ts | 2 + src/discord/voice/manager.test.ts | 268 ++++++++++++++++++++++++ src/discord/voice/manager.ts | 127 +++++++++-- 11 files changed, 555 insertions(+), 12 deletions(-) create mode 100644 src/discord/voice/manager.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bb9bddd6392c..cd56a9a85ad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky. - Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire `messages.statusReactions.{emojis,timing}` into Discord reaction lifecycle control, and compact model-picker `custom_id` keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr. - Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin. +- Discord/Voice reliability: restore runtime DAVE dependency (`@snazzah/davey`), add configurable DAVE join options (`channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance`), clean up voice listeners/session teardown, guard against stale connection events, and trigger controlled rejoin recovery after repeated decrypt failures to improve inbound STT stability under DAVE receive errors. (#25861, #25372, #24883, #24825, #23890, #23105, #22961, #23421, #23278, #23032) - Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall. - Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility. - Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 108ef34d4efe..98a0db693f1e 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -919,6 +919,8 @@ Auto-join example: channelId: "234567890123456789", }, ], + daveEncryption: true, + decryptionFailureTolerance: 24, tts: { provider: "openai", openai: { voice: "alloy" }, @@ -933,6 +935,8 @@ Notes: - `voice.tts` overrides `messages.tts` for voice playback only. - Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable it. +- `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options. +- If receive logs repeatedly show `DecryptionFailed(UnencryptedWhenPassthroughDisabled)`, this may be the upstream `@discordjs/voice` receive bug tracked in [discord.js #11419](https://github.com/discordjs/discord.js/issues/11419). ## Voice messages diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 825acbaadf5a..2aef7982198e 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -255,6 +255,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat channelId: "234567890123456789", }, ], + daveEncryption: true, + decryptionFailureTolerance: 24, tts: { provider: "openai", openai: { voice: "alloy" }, @@ -282,6 +284,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - `spawnSubagentSessions`: opt-in switch for `sessions_spawn({ thread: true })` auto thread creation/binding - `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers. - `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides. +- `channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance` pass through to `@discordjs/voice` DAVE options. - `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated. - `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode). diff --git a/package.json b/package.json index 69657a04cf22..c2f69c7286fb 100644 --- a/package.json +++ b/package.json @@ -159,6 +159,7 @@ "@sinclair/typebox": "0.34.48", "@slack/bolt": "^4.6.0", "@slack/web-api": "^7.14.1", + "@snazzah/davey": "^0.1.9", "@whiskeysockets/baileys": "7.0.0-rc.9", "ajv": "^8.18.0", "chalk": "^5.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd21887d7a89..46a7f41fcb4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: '@slack/web-api': specifier: ^7.14.1 version: 7.14.1 + '@snazzah/davey': + specifier: ^0.1.9 + version: 0.1.9 '@whiskeysockets/baileys': specifier: 7.0.0-rc.9 version: 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) @@ -2722,6 +2725,93 @@ packages: resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} + '@snazzah/davey-android-arm-eabi@0.1.9': + resolution: {integrity: sha512-Dq0WyeVGBw+uQbisV/6PeCQV2ndJozfhZqiNIfQxu6ehIdXB7iHILv+oY+AQN2n+qxiFmLh/MOX9RF+pIWdPbA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@snazzah/davey-android-arm64@0.1.9': + resolution: {integrity: sha512-OE16OZjv7F/JrD7Mzw5eL2gY2vXRPC8S7ZrmkcMyz/sHHJsGHlT+L7X5s56Bec1YDTVmzAsH4UBuvVBoXuIWEQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@snazzah/davey-darwin-arm64@0.1.9': + resolution: {integrity: sha512-z7oORvAPExikFkH6tvHhbUdZd77MYZp9VqbCpKEiI+sisWFVXgHde7F7iH3G4Bz6gUYJfgvKhWXiDRc+0SC4dg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@snazzah/davey-darwin-x64@0.1.9': + resolution: {integrity: sha512-f1LzGyRGlM414KpXml3OgWVSd7CgylcdYaFj/zDBb8bvWjxyvsI9iMeuPfe/cduloxRj8dELde/yCDZtFR6PdQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@snazzah/davey-freebsd-x64@0.1.9': + resolution: {integrity: sha512-k6p3JY2b8rD6j0V9Ql7kBUMR4eJdcpriNwiHltLzmtGuz/nK5RGQdkEP68gTLc+Uj3xs5Cy0jRKmv2xJQBR4sA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@snazzah/davey-linux-arm-gnueabihf@0.1.9': + resolution: {integrity: sha512-xDaAFUC/1+n/YayNwKsqKOBMuW0KI6F0SjgWU+krYTQTVmAKNjOM80IjemrVoqTpBOxBsT80zEtct2wj11CE3Q==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@snazzah/davey-linux-arm64-gnu@0.1.9': + resolution: {integrity: sha512-t1VxFBzWExPNpsNY/9oStdAAuHqFvwZvIO2YPYyVNstxfi2KmAbHMweHUW7xb2ppXuhVQZ4VGmmeXiXcXqhPBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@snazzah/davey-linux-arm64-musl@0.1.9': + resolution: {integrity: sha512-Xvlr+nBPzuFV4PXHufddlt08JsEyu0p8mX2DpqdPxdpysYIH4I8V86yJiS4tk04a6pLBDd8IxTbBwvXJKqd/LQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@snazzah/davey-linux-x64-gnu@0.1.9': + resolution: {integrity: sha512-6Uunc/NxiEkg1reroAKZAGfOtjl1CGa7hfTTVClb2f+DiA8ZRQWBh+3lgkq/0IeL262B4F14X8QRv5Bsv128qw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@snazzah/davey-linux-x64-musl@0.1.9': + resolution: {integrity: sha512-fFQ/n3aWt1lXhxSdy+Ge3gi5bR3VETMVsWhH0gwBALUKrbo3ZzgSktm4lNrXE9i0ncMz/CDpZ5i0wt/N3XphEQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@snazzah/davey-wasm32-wasi@0.1.9': + resolution: {integrity: sha512-xWvzej8YCVlUvzlpmqJMIf0XmLlHqulKZ2e7WNe2TxQmsK+o0zTZqiQYs2MwaEbrNXBhYlHDkdpuwoXkJdscNQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@snazzah/davey-win32-arm64-msvc@0.1.9': + resolution: {integrity: sha512-sTqry/DfltX2OdW1CTLKa3dFYN5FloAEb2yhGsY1i5+Bms6OhwByXfALvyMHYVo61Th2+sD+9BJpQffHFKDA3w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@snazzah/davey-win32-ia32-msvc@0.1.9': + resolution: {integrity: sha512-twD3LwlkGnSwphsCtpGb5ztpBIWEvGdc0iujoVkdzZ6nJiq5p8iaLjJMO4hBm9h3s28fc+1Qd7AMVnagiOasnA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@snazzah/davey-win32-x64-msvc@0.1.9': + resolution: {integrity: sha512-eMnXbv4GoTngWYY538i/qHz2BS+RgSXFsvKltPzKqnqzPzhQZIY7TemEJn3D5yWGfW4qHve9u23rz93FQqnQMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@snazzah/davey@0.1.9': + resolution: {integrity: sha512-vNZk5y+IsxjwzTAXikvzz5pqMLb35YytC64nVF2MAFVhjpXu9ITOKUriZ0JG/llwzCAi56jb5x0cXDRIyE2A2A==} + engines: {node: '>= 10'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -8254,6 +8344,67 @@ snapshots: dependencies: tslib: 2.8.1 + '@snazzah/davey-android-arm-eabi@0.1.9': + optional: true + + '@snazzah/davey-android-arm64@0.1.9': + optional: true + + '@snazzah/davey-darwin-arm64@0.1.9': + optional: true + + '@snazzah/davey-darwin-x64@0.1.9': + optional: true + + '@snazzah/davey-freebsd-x64@0.1.9': + optional: true + + '@snazzah/davey-linux-arm-gnueabihf@0.1.9': + optional: true + + '@snazzah/davey-linux-arm64-gnu@0.1.9': + optional: true + + '@snazzah/davey-linux-arm64-musl@0.1.9': + optional: true + + '@snazzah/davey-linux-x64-gnu@0.1.9': + optional: true + + '@snazzah/davey-linux-x64-musl@0.1.9': + optional: true + + '@snazzah/davey-wasm32-wasi@0.1.9': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@snazzah/davey-win32-arm64-msvc@0.1.9': + optional: true + + '@snazzah/davey-win32-ia32-msvc@0.1.9': + optional: true + + '@snazzah/davey-win32-x64-msvc@0.1.9': + optional: true + + '@snazzah/davey@0.1.9': + optionalDependencies: + '@snazzah/davey-android-arm-eabi': 0.1.9 + '@snazzah/davey-android-arm64': 0.1.9 + '@snazzah/davey-darwin-arm64': 0.1.9 + '@snazzah/davey-darwin-x64': 0.1.9 + '@snazzah/davey-freebsd-x64': 0.1.9 + '@snazzah/davey-linux-arm-gnueabihf': 0.1.9 + '@snazzah/davey-linux-arm64-gnu': 0.1.9 + '@snazzah/davey-linux-arm64-musl': 0.1.9 + '@snazzah/davey-linux-x64-gnu': 0.1.9 + '@snazzah/davey-linux-x64-musl': 0.1.9 + '@snazzah/davey-wasm32-wasi': 0.1.9 + '@snazzah/davey-win32-arm64-msvc': 0.1.9 + '@snazzah/davey-win32-ia32-msvc': 0.1.9 + '@snazzah/davey-win32-x64-msvc': 0.1.9 + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.19': diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index bac2c2dcae16..e5fcb3aa6b7f 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1364,6 +1364,10 @@ export const FIELD_HELP: Record = { "Enable Discord voice channel conversations (default: true). Omit channels.discord.voice to keep voice support disabled for the account.", "channels.discord.voice.autoJoin": "Voice channels to auto-join on startup (list of guildId/channelId entries).", + "channels.discord.voice.daveEncryption": + "Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this).", + "channels.discord.voice.decryptionFailureTolerance": + "Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24).", "channels.discord.voice.tts": "Optional TTS overrides for Discord voice playback (merged with messages.tts).", "channels.discord.intents.presence": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index f1706d1af7da..7a12e9293ba2 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -677,6 +677,8 @@ export const FIELD_LABELS: Record = { "channels.discord.intents.guildMembers": "Discord Guild Members Intent", "channels.discord.voice.enabled": "Discord Voice Enabled", "channels.discord.voice.autoJoin": "Discord Voice Auto-Join", + "channels.discord.voice.daveEncryption": "Discord Voice DAVE Encryption", + "channels.discord.voice.decryptionFailureTolerance": "Discord Voice Decrypt Failure Tolerance", "channels.discord.voice.tts": "Discord Voice Text-to-Speech", "channels.discord.pluralkit.enabled": "Discord PluralKit Enabled", "channels.discord.pluralkit.token": "Discord PluralKit Token", diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 0d795c94bb4e..1b43ddeb48b3 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -107,6 +107,10 @@ export type DiscordVoiceConfig = { enabled?: boolean; /** Voice channels to auto-join on startup. */ autoJoin?: DiscordVoiceAutoJoinConfig[]; + /** Enable/disable DAVE end-to-end encryption (default: true; Discord may require this). */ + daveEncryption?: boolean; + /** Consecutive decrypt failures before DAVE session reinitialization (default: 24). */ + decryptionFailureTolerance?: number; /** Optional TTS overrides for Discord voice output. */ tts?: TtsConfig; }; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index bccbb5bdd35f..806eb8f89ce3 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -315,6 +315,8 @@ const DiscordVoiceSchema = z .object({ enabled: z.boolean().optional(), autoJoin: z.array(DiscordVoiceAutoJoinSchema).optional(), + daveEncryption: z.boolean().optional(), + decryptionFailureTolerance: z.number().int().min(0).optional(), tts: TtsConfigSchema.optional(), }) .strict() diff --git a/src/discord/voice/manager.test.ts b/src/discord/voice/manager.test.ts new file mode 100644 index 000000000000..70dbcf5170c0 --- /dev/null +++ b/src/discord/voice/manager.test.ts @@ -0,0 +1,268 @@ +import { ChannelType } from "@buape/carbon"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + createConnectionMock, + joinVoiceChannelMock, + entersStateMock, + createAudioPlayerMock, + resolveAgentRouteMock, +} = vi.hoisted(() => { + type EventHandler = (...args: unknown[]) => unknown; + type MockConnection = { + destroy: ReturnType; + subscribe: ReturnType; + on: ReturnType; + off: ReturnType; + receiver: { + speaking: { + on: ReturnType; + off: ReturnType; + }; + subscribe: ReturnType; + }; + handlers: Map; + }; + + const createConnectionMock = (): MockConnection => { + const handlers = new Map(); + const connection: MockConnection = { + destroy: vi.fn(), + subscribe: vi.fn(), + on: vi.fn((event: string, handler: EventHandler) => { + handlers.set(event, handler); + }), + off: vi.fn(), + receiver: { + speaking: { + on: vi.fn(), + off: vi.fn(), + }, + subscribe: vi.fn(() => ({ + on: vi.fn(), + [Symbol.asyncIterator]: async function* () {}, + })), + }, + handlers, + }; + return connection; + }; + + return { + createConnectionMock, + joinVoiceChannelMock: vi.fn(() => createConnectionMock()), + entersStateMock: vi.fn(async (_target?: unknown, _state?: string, _timeoutMs?: number) => { + return undefined; + }), + createAudioPlayerMock: vi.fn(() => ({ + on: vi.fn(), + off: vi.fn(), + stop: vi.fn(), + play: vi.fn(), + state: { status: "idle" }, + })), + resolveAgentRouteMock: vi.fn(() => ({ agentId: "agent-1", sessionKey: "discord:g1:c1" })), + }; +}); + +vi.mock("@discordjs/voice", () => ({ + AudioPlayerStatus: { Playing: "playing", Idle: "idle" }, + EndBehaviorType: { AfterSilence: "AfterSilence" }, + VoiceConnectionStatus: { + Ready: "ready", + Disconnected: "disconnected", + Destroyed: "destroyed", + Signalling: "signalling", + Connecting: "connecting", + }, + createAudioPlayer: createAudioPlayerMock, + createAudioResource: vi.fn(), + entersState: entersStateMock, + joinVoiceChannel: joinVoiceChannelMock, +})); + +vi.mock("../../routing/resolve-route.js", () => ({ + resolveAgentRoute: resolveAgentRouteMock, +})); + +let managerModule: typeof import("./manager.js"); + +function createClient() { + return { + fetchChannel: vi.fn(async (channelId: string) => ({ + id: channelId, + guildId: "g1", + type: ChannelType.GuildVoice, + })), + getPlugin: vi.fn(() => ({ + getGatewayAdapterCreator: vi.fn(() => vi.fn()), + })), + fetchMember: vi.fn(), + fetchUser: vi.fn(), + }; +} + +function createRuntime() { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; +} + +describe("DiscordVoiceManager", () => { + beforeAll(async () => { + managerModule = await import("./manager.js"); + }); + + beforeEach(() => { + joinVoiceChannelMock.mockReset(); + joinVoiceChannelMock.mockImplementation(() => createConnectionMock()); + entersStateMock.mockReset(); + entersStateMock.mockResolvedValue(undefined); + createAudioPlayerMock.mockClear(); + resolveAgentRouteMock.mockClear(); + }); + + it("keeps the new session when an old disconnected handler fires", async () => { + const oldConnection = createConnectionMock(); + const newConnection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(oldConnection).mockReturnValueOnce(newConnection); + entersStateMock.mockImplementation(async (target: unknown, status?: string) => { + if (target === oldConnection && (status === "signalling" || status === "connecting")) { + throw new Error("old disconnected"); + } + return undefined; + }); + + const manager = new managerModule.DiscordVoiceManager({ + client: createClient() as never, + cfg: {}, + discordConfig: {}, + accountId: "default", + runtime: createRuntime(), + }); + + await manager.join({ guildId: "g1", channelId: "c1" }); + await manager.join({ guildId: "g1", channelId: "c2" }); + + const oldDisconnected = oldConnection.handlers.get("disconnected"); + expect(oldDisconnected).toBeTypeOf("function"); + await oldDisconnected?.(); + + expect(manager.status()).toEqual([ + { + ok: true, + message: "connected: guild g1 channel c2", + guildId: "g1", + channelId: "c2", + }, + ]); + }); + + it("keeps the new session when an old destroyed handler fires", async () => { + const oldConnection = createConnectionMock(); + const newConnection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(oldConnection).mockReturnValueOnce(newConnection); + + const manager = new managerModule.DiscordVoiceManager({ + client: createClient() as never, + cfg: {}, + discordConfig: {}, + accountId: "default", + runtime: createRuntime(), + }); + + await manager.join({ guildId: "g1", channelId: "c1" }); + await manager.join({ guildId: "g1", channelId: "c2" }); + + const oldDestroyed = oldConnection.handlers.get("destroyed"); + expect(oldDestroyed).toBeTypeOf("function"); + oldDestroyed?.(); + + expect(manager.status()).toEqual([ + { + ok: true, + message: "connected: guild g1 channel c2", + guildId: "g1", + channelId: "c2", + }, + ]); + }); + + it("removes voice listeners on leave", async () => { + const connection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(connection); + const manager = new managerModule.DiscordVoiceManager({ + client: createClient() as never, + cfg: {}, + discordConfig: {}, + accountId: "default", + runtime: createRuntime(), + }); + + await manager.join({ guildId: "g1", channelId: "c1" }); + await manager.leave({ guildId: "g1" }); + + const player = createAudioPlayerMock.mock.results[0]?.value; + expect(connection.receiver.speaking.off).toHaveBeenCalledWith("start", expect.any(Function)); + expect(connection.off).toHaveBeenCalledWith("disconnected", expect.any(Function)); + expect(connection.off).toHaveBeenCalledWith("destroyed", expect.any(Function)); + expect(player.off).toHaveBeenCalledWith("error", expect.any(Function)); + }); + + it("passes DAVE options to joinVoiceChannel", async () => { + const manager = new managerModule.DiscordVoiceManager({ + client: createClient() as never, + cfg: {}, + discordConfig: { + voice: { + daveEncryption: false, + decryptionFailureTolerance: 8, + }, + }, + accountId: "default", + runtime: createRuntime(), + }); + + await manager.join({ guildId: "g1", channelId: "c1" }); + + expect(joinVoiceChannelMock).toHaveBeenCalledWith( + expect.objectContaining({ + daveEncryption: false, + decryptionFailureTolerance: 8, + }), + ); + }); + + it("attempts rejoin after repeated decrypt failures", async () => { + const manager = new managerModule.DiscordVoiceManager({ + client: createClient() as never, + cfg: {}, + discordConfig: {}, + accountId: "default", + runtime: createRuntime(), + }); + + await manager.join({ guildId: "g1", channelId: "c1" }); + + const entry = (manager as { sessions: Map }).sessions.get("g1"); + expect(entry).toBeDefined(); + (manager as { handleReceiveError: (e: unknown, err: unknown) => void }).handleReceiveError( + entry, + new Error("Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)"), + ); + (manager as { handleReceiveError: (e: unknown, err: unknown) => void }).handleReceiveError( + entry, + new Error("Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)"), + ); + (manager as { handleReceiveError: (e: unknown, err: unknown) => void }).handleReceiveError( + entry, + new Error("Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)"), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(joinVoiceChannelMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/discord/voice/manager.ts b/src/discord/voice/manager.ts index f9da749a74f5..c246b280fb44 100644 --- a/src/discord/voice/manager.ts +++ b/src/discord/voice/manager.ts @@ -45,6 +45,9 @@ const MIN_SEGMENT_SECONDS = 0.35; const SILENCE_DURATION_MS = 1_000; const PLAYBACK_READY_TIMEOUT_MS = 15_000; const SPEAKING_READY_TIMEOUT_MS = 60_000; +const DECRYPT_FAILURE_WINDOW_MS = 30_000; +const DECRYPT_FAILURE_RECONNECT_THRESHOLD = 3; +const DECRYPT_FAILURE_PATTERN = /DecryptionFailed\(/; const logger = createSubsystemLogger("discord/voice"); @@ -69,6 +72,9 @@ type VoiceSessionEntry = { playbackQueue: Promise; processingQueue: Promise; activeSpeakers: Set; + decryptFailureCount: number; + lastDecryptFailureAt: number; + decryptRecoveryInFlight: boolean; stop: () => void; }; @@ -377,12 +383,21 @@ export class DiscordVoiceManager { } const adapterCreator = voicePlugin.getGatewayAdapterCreator(guildId); + const daveEncryption = this.params.discordConfig.voice?.daveEncryption; + const decryptionFailureTolerance = this.params.discordConfig.voice?.decryptionFailureTolerance; + logVoiceVerbose( + `join: DAVE settings encryption=${daveEncryption === false ? "off" : "on"} tolerance=${ + decryptionFailureTolerance ?? "default" + }`, + ); const connection = joinVoiceChannel({ channelId, guildId, adapterCreator, selfDeaf: false, selfMute: false, + daveEncryption, + decryptionFailureTolerance, }); try { @@ -412,6 +427,17 @@ export class DiscordVoiceManager { const player = createAudioPlayer(); connection.subscribe(player); + let speakingHandler: ((userId: string) => void) | undefined; + let disconnectedHandler: (() => Promise) | undefined; + let destroyedHandler: (() => void) | undefined; + let playerErrorHandler: ((err: Error) => void) | undefined; + const clearSessionIfCurrent = () => { + const active = this.sessions.get(guildId); + if (active?.connection === connection) { + this.sessions.delete(guildId); + } + }; + const entry: VoiceSessionEntry = { guildId, channelId, @@ -422,37 +448,55 @@ export class DiscordVoiceManager { playbackQueue: Promise.resolve(), processingQueue: Promise.resolve(), activeSpeakers: new Set(), + decryptFailureCount: 0, + lastDecryptFailureAt: 0, + decryptRecoveryInFlight: false, stop: () => { + if (speakingHandler) { + connection.receiver.speaking.off("start", speakingHandler); + } + if (disconnectedHandler) { + connection.off(VoiceConnectionStatus.Disconnected, disconnectedHandler); + } + if (destroyedHandler) { + connection.off(VoiceConnectionStatus.Destroyed, destroyedHandler); + } + if (playerErrorHandler) { + player.off("error", playerErrorHandler); + } player.stop(); connection.destroy(); }, }; - const speakingHandler = (userId: string) => { + speakingHandler = (userId: string) => { void this.handleSpeakingStart(entry, userId).catch((err) => { logger.warn(`discord voice: capture failed: ${formatErrorMessage(err)}`); }); }; - connection.receiver.speaking.on("start", speakingHandler); - connection.on(VoiceConnectionStatus.Disconnected, async () => { + disconnectedHandler = async () => { try { await Promise.race([ entersState(connection, VoiceConnectionStatus.Signalling, 5_000), entersState(connection, VoiceConnectionStatus.Connecting, 5_000), ]); } catch { - this.sessions.delete(guildId); + clearSessionIfCurrent(); connection.destroy(); } - }); - connection.on(VoiceConnectionStatus.Destroyed, () => { - this.sessions.delete(guildId); - }); - - player.on("error", (err) => { + }; + destroyedHandler = () => { + clearSessionIfCurrent(); + }; + playerErrorHandler = (err: Error) => { logger.warn(`discord voice: playback error: ${formatErrorMessage(err)}`); - }); + }; + + connection.receiver.speaking.on("start", speakingHandler); + connection.on(VoiceConnectionStatus.Disconnected, disconnectedHandler); + connection.on(VoiceConnectionStatus.Destroyed, destroyedHandler); + player.on("error", playerErrorHandler); this.sessions.set(guildId, entry); return { @@ -526,7 +570,7 @@ export class DiscordVoiceManager { }, }); stream.on("error", (err) => { - logger.warn(`discord voice: receive error: ${formatErrorMessage(err)}`); + this.handleReceiveError(entry, err); }); try { @@ -537,6 +581,7 @@ export class DiscordVoiceManager { ); return; } + this.resetDecryptFailureState(entry); const { path: wavPath, durationSeconds } = await writeWavFile(pcm); if (durationSeconds < MIN_SEGMENT_SECONDS) { logVoiceVerbose( @@ -654,6 +699,64 @@ export class DiscordVoiceManager { }); } + private handleReceiveError(entry: VoiceSessionEntry, err: unknown) { + const message = formatErrorMessage(err); + logger.warn(`discord voice: receive error: ${message}`); + if (!DECRYPT_FAILURE_PATTERN.test(message)) { + return; + } + const now = Date.now(); + if (now - entry.lastDecryptFailureAt > DECRYPT_FAILURE_WINDOW_MS) { + entry.decryptFailureCount = 0; + } + entry.lastDecryptFailureAt = now; + entry.decryptFailureCount += 1; + if (entry.decryptFailureCount === 1) { + logger.warn( + "discord voice: DAVE decrypt failures detected; voice receive may be unstable (upstream: discordjs/discord.js#11419)", + ); + } + if ( + entry.decryptFailureCount < DECRYPT_FAILURE_RECONNECT_THRESHOLD || + entry.decryptRecoveryInFlight + ) { + return; + } + entry.decryptRecoveryInFlight = true; + this.resetDecryptFailureState(entry); + void this.recoverFromDecryptFailures(entry) + .catch((recoverErr) => + logger.warn(`discord voice: decrypt recovery failed: ${formatErrorMessage(recoverErr)}`), + ) + .finally(() => { + entry.decryptRecoveryInFlight = false; + }); + } + + private resetDecryptFailureState(entry: VoiceSessionEntry) { + entry.decryptFailureCount = 0; + entry.lastDecryptFailureAt = 0; + } + + private async recoverFromDecryptFailures(entry: VoiceSessionEntry) { + const active = this.sessions.get(entry.guildId); + if (!active || active.connection !== entry.connection) { + return; + } + logger.warn( + `discord voice: repeated decrypt failures; attempting rejoin for guild ${entry.guildId} channel ${entry.channelId}`, + ); + const leaveResult = await this.leave({ guildId: entry.guildId }); + if (!leaveResult.ok) { + logger.warn(`discord voice: decrypt recovery leave failed: ${leaveResult.message}`); + return; + } + const result = await this.join({ guildId: entry.guildId, channelId: entry.channelId }); + if (!result.ok) { + logger.warn(`discord voice: rejoin after decrypt failures failed: ${result.message}`); + } + } + private async resolveSpeakerLabel(guildId: string, userId: string): Promise { try { const member = await this.params.client.fetchMember(guildId, userId); From ce1dbeb9866a712b57505e0256e4196806ab3eb6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:27:31 +0000 Subject: [PATCH 314/408] fix(macos): clean warnings and harden gateway/talk config parsing --- apps/macos/Package.resolved | 8 +- .../Sources/OpenClaw/AgentWorkspace.swift | 19 +- apps/macos/Sources/OpenClaw/AppState.swift | 137 ++++---- .../OpenClaw/ExecAllowlistMatcher.swift | 2 +- .../Sources/OpenClaw/ExecApprovals.swift | 14 +- .../OpenClaw/ExecApprovalsSocket.swift | 4 +- .../OpenClaw/ExecShellWrapperParser.swift | 10 +- .../ExecSystemRunCommandValidator.swift | 16 +- .../Sources/OpenClaw/GeneralSettings.swift | 6 +- apps/macos/Sources/OpenClaw/MenuBar.swift | 2 +- .../OpenClaw/OnboardingView+Pages.swift | 301 +++++++++--------- .../OpenClaw/OnboardingView+Workspace.swift | 10 +- .../OpenClaw/SystemRunSettingsView.swift | 8 +- .../Sources/OpenClaw/TalkModeRuntime.swift | 60 +++- .../AgentWorkspaceTests.swift | 14 +- 15 files changed, 329 insertions(+), 282 deletions(-) diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index 0281713738b1..89bbefc5b025 100644 --- a/apps/macos/Package.resolved +++ b/apps/macos/Package.resolved @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", - "version" : "2.8.1" + "revision" : "21d8df80440b1ca3b65fa82e40782f1e5a9e6ba2", + "version" : "2.9.0" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", - "version" : "1.9.1" + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" } }, { diff --git a/apps/macos/Sources/OpenClaw/AgentWorkspace.swift b/apps/macos/Sources/OpenClaw/AgentWorkspace.swift index 57164ebb892d..6340dee2ca52 100644 --- a/apps/macos/Sources/OpenClaw/AgentWorkspace.swift +++ b/apps/macos/Sources/OpenClaw/AgentWorkspace.swift @@ -17,9 +17,14 @@ enum AgentWorkspace { AgentWorkspace.userFilename, AgentWorkspace.bootstrapFilename, ] - enum BootstrapSafety: Equatable { - case safe - case unsafe (reason: String) + struct BootstrapSafety: Equatable { + let unsafeReason: String? + + static let safe = Self(unsafeReason: nil) + + static func blocked(_ reason: String) -> Self { + Self(unsafeReason: reason) + } } static func displayPath(for url: URL) -> String { @@ -71,9 +76,7 @@ enum AgentWorkspace { if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { return .safe } - if !isDir.boolValue { - return .unsafe (reason: "Workspace path points to a file.") - } + if !isDir.boolValue { return .blocked("Workspace path points to a file.") } let agentsURL = self.agentsURL(workspaceURL: workspaceURL) if fm.fileExists(atPath: agentsURL.path) { return .safe @@ -82,9 +85,9 @@ enum AgentWorkspace { let entries = try self.workspaceEntries(workspaceURL: workspaceURL) return entries.isEmpty ? .safe - : .unsafe (reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.") + : .blocked("Folder isn't empty. Choose a new folder or add AGENTS.md first.") } catch { - return .unsafe (reason: "Couldn't inspect the workspace folder.") + return .blocked("Couldn't inspect the workspace folder.") } } diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index e9ca6c353597..ef4917e7768f 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -356,6 +356,70 @@ final class AppState { return trimmed } + private static func updateGatewayString( + _ dictionary: inout [String: Any], + key: String, + value: String?) -> Bool + { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + guard dictionary[key] != nil else { return false } + dictionary.removeValue(forKey: key) + return true + } + if (dictionary[key] as? String) != trimmed { + dictionary[key] = trimmed + return true + } + return false + } + + private static func updatedRemoteGatewayConfig( + current: [String: Any], + transport: RemoteTransport, + remoteUrl: String, + remoteHost: String?, + remoteTarget: String, + remoteIdentity: String) -> (remote: [String: Any], changed: Bool) + { + var remote = current + var changed = false + + switch transport { + case .direct: + changed = Self.updateGatewayString( + &remote, + key: "transport", + value: RemoteTransport.direct.rawValue) || changed + + let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedUrl.isEmpty { + changed = Self.updateGatewayString(&remote, key: "url", value: nil) || changed + } else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) { + changed = Self.updateGatewayString(&remote, key: "url", value: normalizedUrl) || changed + } + + case .ssh: + changed = Self.updateGatewayString(&remote, key: "transport", value: nil) || changed + + if let host = remoteHost { + let existingUrl = (remote["url"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) + let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" + let port = parsedExisting?.port ?? 18789 + let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" + changed = Self.updateGatewayString(&remote, key: "url", value: desiredUrl) || changed + } + + let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget) + changed = Self.updateGatewayString(&remote, key: "sshTarget", value: sanitizedTarget) || changed + changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: remoteIdentity) || changed + } + + return (remote, changed) + } + private func startConfigWatcher() { let configUrl = OpenClawConfigFile.url() self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in @@ -470,69 +534,16 @@ final class AppState { } if connectionMode == .remote { - var remote = gateway["remote"] as? [String: Any] ?? [:] - var remoteChanged = false - - if remoteTransport == .direct { - let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedUrl.isEmpty { - if remote["url"] != nil { - remote.removeValue(forKey: "url") - remoteChanged = true - } - } else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) { - if (remote["url"] as? String) != normalizedUrl { - remote["url"] = normalizedUrl - remoteChanged = true - } - } - if (remote["transport"] as? String) != RemoteTransport.direct.rawValue { - remote["transport"] = RemoteTransport.direct.rawValue - remoteChanged = true - } - } else { - if remote["transport"] != nil { - remote.removeValue(forKey: "transport") - remoteChanged = true - } - if let host = remoteHost { - let existingUrl = (remote["url"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) - let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" - let port = parsedExisting?.port ?? 18789 - let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" - if existingUrl != desiredUrl { - remote["url"] = desiredUrl - remoteChanged = true - } - } - - let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget) - if !sanitizedTarget.isEmpty { - if (remote["sshTarget"] as? String) != sanitizedTarget { - remote["sshTarget"] = sanitizedTarget - remoteChanged = true - } - } else if remote["sshTarget"] != nil { - remote.removeValue(forKey: "sshTarget") - remoteChanged = true - } - - let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedIdentity.isEmpty { - if (remote["sshIdentity"] as? String) != trimmedIdentity { - remote["sshIdentity"] = trimmedIdentity - remoteChanged = true - } - } else if remote["sshIdentity"] != nil { - remote.removeValue(forKey: "sshIdentity") - remoteChanged = true - } - } - - if remoteChanged { - gateway["remote"] = remote + let currentRemote = gateway["remote"] as? [String: Any] ?? [:] + let updated = Self.updatedRemoteGatewayConfig( + current: currentRemote, + transport: remoteTransport, + remoteUrl: remoteUrl, + remoteHost: remoteHost, + remoteTarget: remoteTarget, + remoteIdentity: remoteIdentity) + if updated.changed { + gateway["remote"] = updated.remote changed = true } } diff --git a/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift b/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift index 2dd720741bbb..ad40d2c38037 100644 --- a/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift +++ b/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift @@ -8,7 +8,7 @@ enum ExecAllowlistMatcher { for entry in entries { switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) { - case .valid(let pattern): + case let .valid(pattern): let target = resolvedPath ?? rawExecutable if self.matches(pattern: pattern, target: target) { return entry } case .invalid: diff --git a/apps/macos/Sources/OpenClaw/ExecApprovals.swift b/apps/macos/Sources/OpenClaw/ExecApprovals.swift index 08567cd0b09a..73aa3899d824 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovals.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovals.swift @@ -439,9 +439,9 @@ enum ExecApprovalsStore { static func addAllowlistEntry(agentId: String?, pattern: String) -> ExecAllowlistPatternValidationReason? { let normalizedPattern: String switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { - case .valid(let validPattern): + case let .valid(validPattern): normalizedPattern = validPattern - case .invalid(let reason): + case let .invalid(reason): return reason } @@ -571,7 +571,7 @@ enum ExecApprovalsStore { private static func normalizedPattern(_ pattern: String?) -> String? { switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { - case .valid(let normalized): + case let .valid(normalized): return normalized.lowercased() case .invalid(.empty): return nil @@ -587,7 +587,7 @@ enum ExecApprovalsStore { let normalizedResolved = trimmedResolved.isEmpty ? nil : trimmedResolved switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) { - case .valid(let pattern): + case let .valid(pattern): return ExecAllowlistEntry( id: entry.id, pattern: pattern, @@ -596,7 +596,7 @@ enum ExecApprovalsStore { lastResolvedPath: normalizedResolved) case .invalid: switch ExecApprovalHelpers.validateAllowlistPattern(trimmedResolved) { - case .valid(let migratedPattern): + case let .valid(migratedPattern): return ExecAllowlistEntry( id: entry.id, pattern: migratedPattern, @@ -629,7 +629,7 @@ enum ExecApprovalsStore { let normalizedResolvedPath = trimmedResolvedPath.isEmpty ? nil : trimmedResolvedPath switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) { - case .valid(let pattern): + case let .valid(pattern): normalized.append( ExecAllowlistEntry( id: migrated.id, @@ -637,7 +637,7 @@ enum ExecApprovalsStore { lastUsedAt: migrated.lastUsedAt, lastUsedCommand: migrated.lastUsedCommand, lastResolvedPath: normalizedResolvedPath)) - case .invalid(let reason): + case let .invalid(reason): if dropInvalid { rejected.append( ExecAllowlistRejectedEntry( diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift index 130e94731177..16f8db913056 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -366,9 +366,9 @@ private enum ExecHostExecutor { rawCommand: request.rawCommand) let displayCommand: String switch validatedCommand { - case .ok(let resolved): + case let .ok(resolved): displayCommand = resolved.displayCommand - case .invalid(let message): + case let .invalid(message): return self.errorResponse( code: "INVALID_REQUEST", message: message, diff --git a/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift b/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift index ca6a934adb51..06851a7d0657 100644 --- a/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift +++ b/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift @@ -63,11 +63,11 @@ enum ExecShellWrapperParser { private static func extractPayload(command: [String], spec: WrapperSpec) -> String? { switch spec.kind { case .posix: - return self.extractPosixInlineCommand(command) + self.extractPosixInlineCommand(command) case .cmd: - return self.extractCmdInlineCommand(command) + self.extractCmdInlineCommand(command) case .powershell: - return self.extractPowerShellInlineCommand(command) + self.extractPowerShellInlineCommand(command) } } @@ -81,7 +81,9 @@ enum ExecShellWrapperParser { } private static func extractCmdInlineCommand(_ command: [String]) -> String? { - guard let idx = command.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" }) else { + guard let idx = command + .firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" }) + else { return nil } let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ") diff --git a/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift index f3bdac5e71e7..707a46322d8f 100644 --- a/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift +++ b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift @@ -77,11 +77,10 @@ enum ExecSystemRunCommandValidator { let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command) let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv - let inferred: String - if let shellCommand, !mustBindDisplayToFullArgv { - inferred = shellCommand + let inferred: String = if let shellCommand, !mustBindDisplayToFullArgv { + shellCommand } else { - inferred = ExecCommandFormatter.displayString(for: command) + ExecCommandFormatter.displayString(for: command) } if let raw = normalizedRaw, raw != inferred { @@ -189,7 +188,7 @@ enum ExecSystemRunCommandValidator { } var appletIndex = 1 - if appletIndex < argv.count && argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" { + if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" { appletIndex += 1 } guard appletIndex < argv.count else { @@ -255,14 +254,13 @@ enum ExecSystemRunCommandValidator { return false } - let inlineCommandIndex: Int? - if wrapper == "powershell" || wrapper == "pwsh" { - inlineCommandIndex = self.resolveInlineCommandTokenIndex( + let inlineCommandIndex: Int? = if wrapper == "powershell" || wrapper == "pwsh" { + self.resolveInlineCommandTokenIndex( wrapperArgv, flags: self.powershellInlineCommandFlags, allowCombinedC: false) } else { - inlineCommandIndex = self.resolveInlineCommandTokenIndex( + self.resolveInlineCommandTokenIndex( wrapperArgv, flags: self.posixInlineCommandFlags, allowCombinedC: true) diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index 60cfdfb1d737..4dae858771cc 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -304,8 +304,7 @@ struct GeneralSettings: View { .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } Text( - "Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1." - ) + "Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1.") .font(.caption) .foregroundStyle(.secondary) .padding(.leading, self.remoteLabelWidth + 10) @@ -549,8 +548,7 @@ extension GeneralSettings { } guard Self.isValidWsUrl(trimmedUrl) else { self.remoteStatus = .failed( - "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)" - ) + "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)") return } } else { diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index 00e2a9be0a63..d7ab72ce86f6 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -431,7 +431,7 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding { } } -extension SparkleUpdaterController: @preconcurrency SPUUpdaterDelegate {} +extension SparkleUpdaterController: SPUUpdaterDelegate {} private func isDeveloperIDSigned(bundleURL: URL) -> Bool { var staticCode: SecStaticCode? diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 5b05ab164c2d..ed40bd2ed58b 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -87,19 +87,9 @@ extension OnboardingView { self.onboardingCard(spacing: 12, padding: 14) { VStack(alignment: .leading, spacing: 10) { - let localSubtitle: String = { - guard let probe = self.localGatewayProbe else { - return "Gateway starts automatically on this Mac." - } - let base = probe.expected - ? "Existing gateway detected" - : "Port \(probe.port) already in use" - let command = probe.command.isEmpty ? "" : " (\(probe.command) pid \(probe.pid))" - return "\(base)\(command). Will attach." - }() self.connectionChoiceButton( title: "This Mac", - subtitle: localSubtitle, + subtitle: self.localGatewaySubtitle, selected: self.state.connectionMode == .local) { self.selectLocalGateway() @@ -107,50 +97,7 @@ extension OnboardingView { Divider().padding(.vertical, 4) - HStack(spacing: 8) { - Image(systemName: "dot.radiowaves.left.and.right") - .font(.caption) - .foregroundStyle(.secondary) - Text(self.gatewayDiscovery.statusText) - .font(.caption) - .foregroundStyle(.secondary) - if self.gatewayDiscovery.gateways.isEmpty { - ProgressView().controlSize(.small) - Button("Refresh") { - self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0) - } - .buttonStyle(.link) - .help("Retry Tailscale discovery (DNS-SD).") - } - Spacer(minLength: 0) - } - - if self.gatewayDiscovery.gateways.isEmpty { - Text("Searching for nearby gateways…") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 4) - } else { - VStack(alignment: .leading, spacing: 6) { - Text("Nearby gateways") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 4) - ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in - self.connectionChoiceButton( - title: gateway.displayName, - subtitle: self.gatewaySubtitle(for: gateway), - selected: self.isSelectedGateway(gateway)) - { - self.selectRemoteGateway(gateway) - } - } - } - .padding(8) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(NSColor.controlBackgroundColor))) - } + self.gatewayDiscoverySection() self.connectionChoiceButton( title: "Configure later", @@ -160,104 +107,168 @@ extension OnboardingView { self.selectUnconfiguredGateway() } - Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") { - withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { - self.showAdvancedConnection.toggle() + self.advancedConnectionSection() + } + } + } + } + + private var localGatewaySubtitle: String { + guard let probe = self.localGatewayProbe else { + return "Gateway starts automatically on this Mac." + } + let base = probe.expected + ? "Existing gateway detected" + : "Port \(probe.port) already in use" + let command = probe.command.isEmpty ? "" : " (\(probe.command) pid \(probe.pid))" + return "\(base)\(command). Will attach." + } + + @ViewBuilder + private func gatewayDiscoverySection() -> some View { + HStack(spacing: 8) { + Image(systemName: "dot.radiowaves.left.and.right") + .font(.caption) + .foregroundStyle(.secondary) + Text(self.gatewayDiscovery.statusText) + .font(.caption) + .foregroundStyle(.secondary) + if self.gatewayDiscovery.gateways.isEmpty { + ProgressView().controlSize(.small) + Button("Refresh") { + self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0) + } + .buttonStyle(.link) + .help("Retry Tailscale discovery (DNS-SD).") + } + Spacer(minLength: 0) + } + + if self.gatewayDiscovery.gateways.isEmpty { + Text("Searching for nearby gateways…") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 4) + } else { + VStack(alignment: .leading, spacing: 6) { + Text("Nearby gateways") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 4) + ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in + self.connectionChoiceButton( + title: gateway.displayName, + subtitle: self.gatewaySubtitle(for: gateway), + selected: self.isSelectedGateway(gateway)) + { + self.selectRemoteGateway(gateway) + } + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor))) + } + } + + @ViewBuilder + private func advancedConnectionSection() -> some View { + Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") { + withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { + self.showAdvancedConnection.toggle() + } + if self.showAdvancedConnection, self.state.connectionMode != .remote { + self.state.connectionMode = .remote + } + } + .buttonStyle(.link) + + if self.showAdvancedConnection { + let labelWidth: CGFloat = 110 + let fieldWidth: CGFloat = 320 + + VStack(alignment: .leading, spacing: 10) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text("Transport") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + Picker("Transport", selection: self.$state.remoteTransport) { + Text("SSH tunnel").tag(AppState.RemoteTransport.ssh) + Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct) } - if self.showAdvancedConnection, self.state.connectionMode != .remote { - self.state.connectionMode = .remote + .pickerStyle(.segmented) + .frame(width: fieldWidth) + } + if self.state.remoteTransport == .direct { + GridRow { + Text("Gateway URL") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) } } - .buttonStyle(.link) - - if self.showAdvancedConnection { - let labelWidth: CGFloat = 110 - let fieldWidth: CGFloat = 320 - - VStack(alignment: .leading, spacing: 10) { - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { - GridRow { - Text("Transport") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - Picker("Transport", selection: self.$state.remoteTransport) { - Text("SSH tunnel").tag(AppState.RemoteTransport.ssh) - Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct) - } - .pickerStyle(.segmented) - .frame(width: fieldWidth) - } - if self.state.remoteTransport == .direct { - GridRow { - Text("Gateway URL") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - } - if self.state.remoteTransport == .ssh { - GridRow { - Text("SSH target") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("user@host[:port]", text: self.$state.remoteTarget) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - if let message = CommandResolver - .sshTargetValidationMessage(self.state.remoteTarget) - { - GridRow { - Text("") - .frame(width: labelWidth, alignment: .leading) - Text(message) - .font(.caption) - .foregroundStyle(.red) - .frame(width: fieldWidth, alignment: .leading) - } - } - GridRow { - Text("Identity file") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - GridRow { - Text("Project root") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - GridRow { - Text("CLI path") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField( - "/Applications/OpenClaw.app/.../openclaw", - text: self.$state.remoteCliPath) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - } + if self.state.remoteTransport == .ssh { + GridRow { + Text("SSH target") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("user@host[:port]", text: self.$state.remoteTarget) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + if let message = CommandResolver + .sshTargetValidationMessage(self.state.remoteTarget) + { + GridRow { + Text("") + .frame(width: labelWidth, alignment: .leading) + Text(message) + .font(.caption) + .foregroundStyle(.red) + .frame(width: fieldWidth, alignment: .leading) } - - Text(self.state.remoteTransport == .direct - ? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert." - : "Tip: keep Tailscale enabled so your gateway stays reachable.") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) } - .transition(.opacity.combined(with: .move(edge: .top))) + GridRow { + Text("Identity file") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + GridRow { + Text("Project root") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + GridRow { + Text("CLI path") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField( + "/Applications/OpenClaw.app/.../openclaw", + text: self.$state.remoteCliPath) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } } } + + Text(self.state.remoteTransport == .direct + ? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert." + : "Tip: keep Tailscale enabled so your gateway stays reachable.") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) } + .transition(.opacity.combined(with: .move(edge: .top))) } } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift index 1895b2af94f7..7538f846b890 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift @@ -13,8 +13,10 @@ extension OnboardingView { guard self.state.connectionMode == .local else { return } let configured = await self.loadAgentWorkspace() let url = AgentWorkspace.resolveWorkspaceURL(from: configured) - switch AgentWorkspace.bootstrapSafety(for: url) { - case .safe: + let safety = AgentWorkspace.bootstrapSafety(for: url) + if let reason = safety.unsafeReason { + self.workspaceStatus = "Workspace not touched: \(reason)" + } else { do { _ = try AgentWorkspace.bootstrap(workspaceURL: url) if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -23,8 +25,6 @@ extension OnboardingView { } catch { self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" } - case let .unsafe (reason): - self.workspaceStatus = "Workspace not touched: \(reason)" } self.refreshBootstrapStatus() } @@ -54,7 +54,7 @@ extension OnboardingView { do { let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) - if case let .unsafe (reason) = AgentWorkspace.bootstrapSafety(for: url) { + if let reason = AgentWorkspace.bootstrapSafety(for: url).unsafeReason { self.workspaceStatus = "Workspace not created: \(reason)" return } diff --git a/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift b/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift index a6d81f50bcac..7c047e01d03c 100644 --- a/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift +++ b/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift @@ -383,12 +383,12 @@ final class ExecApprovalsSettingsModel { func addEntry(_ pattern: String) -> ExecAllowlistPatternValidationReason? { guard !self.isDefaultsScope else { return nil } switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { - case .valid(let normalizedPattern): + case let .valid(normalizedPattern): self.entries.append(ExecAllowlistEntry(pattern: normalizedPattern, lastUsedAt: nil)) let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) self.allowlistValidationMessage = rejected.first?.reason.message return rejected.first?.reason - case .invalid(let reason): + case let .invalid(reason): self.allowlistValidationMessage = reason.message return reason } @@ -400,9 +400,9 @@ final class ExecApprovalsSettingsModel { guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return nil } var next = entry switch ExecApprovalHelpers.validateAllowlistPattern(next.pattern) { - case .valid(let normalizedPattern): + case let .valid(normalizedPattern): next.pattern = normalizedPattern - case .invalid(let reason): + case let .invalid(reason): self.allowlistValidationMessage = reason.message return reason } diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index 70184ce9cc71..a8d8008c6530 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -810,25 +810,59 @@ extension TalkModeRuntime { return trimmed.isEmpty ? nil : trimmed } + private static func normalizedTalkProviderConfig(_ value: AnyCodable) -> [String: AnyCodable]? { + if let typed = value.value as? [String: AnyCodable] { + return typed + } + if let foundation = value.value as? [String: Any] { + return foundation.mapValues(AnyCodable.init) + } + if let nsDict = value.value as? NSDictionary { + var converted: [String: AnyCodable] = [:] + for case let (key as String, raw) in nsDict { + converted[key] = AnyCodable(raw) + } + return converted + } + return nil + } + + private static func normalizedTalkProviders(_ raw: AnyCodable?) -> [String: [String: AnyCodable]] { + guard let raw else { return [:] } + var providerMap: [String: AnyCodable] = [:] + if let typed = raw.value as? [String: AnyCodable] { + providerMap = typed + } else if let foundation = raw.value as? [String: Any] { + providerMap = foundation.mapValues(AnyCodable.init) + } else if let nsDict = raw.value as? NSDictionary { + for case let (key as String, value) in nsDict { + providerMap[key] = AnyCodable(value) + } + } else { + return [:] + } + + return providerMap.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in + guard + let providerID = Self.normalizedTalkProviderID(entry.key), + let providerConfig = Self.normalizedTalkProviderConfig(entry.value) + else { return } + acc[providerID] = providerConfig + } + } + static func selectTalkProviderConfig( _ talk: [String: AnyCodable]?) -> TalkProviderConfigSelection? { guard let talk else { return nil } let rawProvider = talk["provider"]?.stringValue - let rawProviders = talk["providers"]?.dictionaryValue + let rawProviders = talk["providers"] let hasNormalizedPayload = rawProvider != nil || rawProviders != nil if hasNormalizedPayload { - let normalizedProviders = - rawProviders?.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in - guard - let providerID = Self.normalizedTalkProviderID(entry.key), - let providerConfig = entry.value.dictionaryValue - else { return } - acc[providerID] = providerConfig - } ?? [:] + let normalizedProviders = Self.normalizedTalkProviders(rawProviders) let providerID = Self.normalizedTalkProviderID(rawProvider) ?? - normalizedProviders.keys.sorted().first ?? + normalizedProviders.keys.min() ?? Self.defaultTalkProvider return TalkProviderConfigSelection( provider: providerID, @@ -877,14 +911,14 @@ extension TalkModeRuntime { let apiKey = activeConfig?["apiKey"]?.stringValue let resolvedVoice: String? = if activeProvider == Self.defaultTalkProvider { (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ?? - (envVoice?.isEmpty == false ? envVoice : nil) ?? - (sagVoice?.isEmpty == false ? sagVoice : nil) + (envVoice?.isEmpty == false ? envVoice : nil) ?? + (sagVoice?.isEmpty == false ? sagVoice : nil) } else { (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) } let resolvedApiKey: String? = if activeProvider == Self.defaultTalkProvider { (envApiKey?.isEmpty == false ? envApiKey : nil) ?? - (apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil) + (apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil) } else { nil } diff --git a/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift b/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift index 6d5e4a37efd0..8794a3f22fce 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift @@ -59,12 +59,7 @@ struct AgentWorkspaceTests { try "hello".write(to: marker, atomically: true, encoding: .utf8) let result = AgentWorkspace.bootstrapSafety(for: tmp) - switch result { - case .unsafe: - break - case .safe: - #expect(Bool(false), "Expected unsafe bootstrap safety result.") - } + #expect(result.unsafeReason != nil) } @Test @@ -77,12 +72,7 @@ struct AgentWorkspaceTests { try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8) let result = AgentWorkspace.bootstrapSafety(for: tmp) - switch result { - case .safe: - break - case .unsafe: - #expect(Bool(false), "Expected safe bootstrap safety result.") - } + #expect(result.unsafeReason == nil) } @Test From ee6fec36eb0d6e8f30c2c7bc1795609804582531 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:28:02 +0000 Subject: [PATCH 315/408] docs(discord): document DAVE defaults and decrypt recovery --- docs/channels/discord.md | 14 ++++++++++++++ docs/gateway/configuration-reference.md | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 98a0db693f1e..31913842e4db 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -936,6 +936,8 @@ Notes: - `voice.tts` overrides `messages.tts` for voice playback only. - Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable it. - `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options. +- `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset. +- OpenClaw also watches receive decrypt failures and auto-recovers by leaving/rejoining the voice channel after repeated failures in a short window. - If receive logs repeatedly show `DecryptionFailed(UnencryptedWhenPassthroughDisabled)`, this may be the upstream `@discordjs/voice` receive bug tracked in [discord.js #11419](https://github.com/discordjs/discord.js/issues/11419). ## Voice messages @@ -1012,6 +1014,18 @@ openclaw logs --follow If you set `channels.discord.allowBots=true`, use strict mention and allowlist rules to avoid loop behavior. + + + + - keep OpenClaw current (`openclaw update`) so the Discord voice receive recovery logic is present + - confirm `channels.discord.voice.daveEncryption=true` (default) + - start from `channels.discord.voice.decryptionFailureTolerance=24` (upstream default) and tune only if needed + - watch logs for: + - `discord voice: DAVE decrypt failures detected` + - `discord voice: repeated decrypt failures; attempting rejoin` + - if failures continue after automatic rejoin, collect logs and compare against [discord.js #11419](https://github.com/discordjs/discord.js/issues/11419) + + ## Configuration reference pointers diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 2aef7982198e..432e472f21dd 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -284,7 +284,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - `spawnSubagentSessions`: opt-in switch for `sessions_spawn({ thread: true })` auto thread creation/binding - `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers. - `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides. -- `channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance` pass through to `@discordjs/voice` DAVE options. +- `channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance` pass through to `@discordjs/voice` DAVE options (`true` and `24` by default). +- OpenClaw additionally attempts voice receive recovery by leaving/rejoining a voice session after repeated decrypt failures. - `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated. - `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode). From a1a6235c6633090e4071bc63104ef3c52014c5a2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:31:17 +0000 Subject: [PATCH 316/408] test: bridge discord voice private casts via unknown --- src/discord/voice/manager.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/discord/voice/manager.test.ts b/src/discord/voice/manager.test.ts index 70dbcf5170c0..ab13304b5e37 100644 --- a/src/discord/voice/manager.test.ts +++ b/src/discord/voice/manager.test.ts @@ -246,17 +246,23 @@ describe("DiscordVoiceManager", () => { await manager.join({ guildId: "g1", channelId: "c1" }); - const entry = (manager as { sessions: Map }).sessions.get("g1"); + const entry = (manager as unknown as { sessions: Map }).sessions.get("g1"); expect(entry).toBeDefined(); - (manager as { handleReceiveError: (e: unknown, err: unknown) => void }).handleReceiveError( + ( + manager as unknown as { handleReceiveError: (e: unknown, err: unknown) => void } + ).handleReceiveError( entry, new Error("Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)"), ); - (manager as { handleReceiveError: (e: unknown, err: unknown) => void }).handleReceiveError( + ( + manager as unknown as { handleReceiveError: (e: unknown, err: unknown) => void } + ).handleReceiveError( entry, new Error("Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)"), ); - (manager as { handleReceiveError: (e: unknown, err: unknown) => void }).handleReceiveError( + ( + manager as unknown as { handleReceiveError: (e: unknown, err: unknown) => void } + ).handleReceiveError( entry, new Error("Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)"), ); From b0f392580b5bb9cb838fb3cccd424c47d66846f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:10:29 +0000 Subject: [PATCH 317/408] docs(changelog): remove next-release shipping sentence --- CHANGELOG.md | 66 ++++++++++++++++++++++++++-------------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd56a9a85ad7..3fd6ca5d44a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,18 +38,18 @@ Docs: https://docs.openclaw.ai - Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. - Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch. - Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. -- Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting. - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. -- Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. This ships in the next npm release. Thanks @GCXWLP for reporting. -- Zalo/Group policy: enforce sender authorization for group messages with `groupPolicy` + `groupAllowFrom` (fallback to `allowFrom`), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Telegram: enforce DM authorization before media download/write (including media groups) and move telegram inbound activity tracking after DM authorization, preventing unauthorized sender-triggered inbound media disk writes. This ships in the next npm release. Thanks @v8hid for reporting. -- Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so `dmPolicy: "allowlist"` with empty `allowedUserIds` rejects all senders instead of allowing unauthorized dispatch. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. Thanks @GCXWLP for reporting. +- Zalo/Group policy: enforce sender authorization for group messages with `groupPolicy` + `groupAllowFrom` (fallback to `allowFrom`), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. Thanks @tdjackey for reporting. +- Security/Telegram: enforce DM authorization before media download/write (including media groups) and move telegram inbound activity tracking after DM authorization, preventing unauthorized sender-triggered inbound media disk writes. Thanks @v8hid for reporting. +- Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. Thanks @tdjackey for reporting. +- Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so `dmPolicy: "allowlist"` with empty `allowedUserIds` rejects all senders instead of allowing unauthorized dispatch. Thanks @tdjackey for reporting. +- Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. Thanks @tdjackey for reporting. +- Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. Thanks @tdjackey for reporting. +- Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting. +- Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. Thanks @tdjackey for reporting. +- Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. Thanks @tdjackey for reporting. - Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. - Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. - Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. @@ -124,7 +124,7 @@ Docs: https://docs.openclaw.ai - Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn. - Channels/WhatsApp: accept `channels.whatsapp.enabled` in config validation to match built-in channel auto-enable behavior, preventing `Unrecognized key: "enabled"` failures during channel setup. (#24263) - Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc. -- Security/ACP: harden ACP client permission auto-approval to require trusted core tool IDs, ignore untrusted `toolCall.kind` hints, and scope `read` auto-approval to the active working directory so unknown tool names and out-of-scope file reads always prompt. This ships in the next npm release. Thanks @nedlir for reporting. +- Security/ACP: harden ACP client permission auto-approval to require trusted core tool IDs, ignore untrusted `toolCall.kind` hints, and scope `read` auto-approval to the active working directory so unknown tool names and out-of-scope file reads always prompt. Thanks @nedlir for reporting. - Security/Skills: escape user-controlled prompt, filename, and output-path values in `openai-image-gen` HTML gallery generation to prevent stored XSS in generated `index.html` output. (#12538) Thanks @CornBrother0x. - Security/Skills: harden `skill-creator` packaging by skipping symlink entries and rejecting files whose resolved paths escape the selected skill root. (#24260, #16959) Thanks @CornBrother0x and @vincentkoc. - Security/OTEL: redact sensitive values (API keys, tokens, credential fields) from diagnostics-otel log bodies, log attributes, and error/reason span fields before OTLP export. (#12542) Thanks @brandonwise. @@ -226,12 +226,12 @@ Docs: https://docs.openclaw.ai - Config/Channels: when `plugins.allow` is active, auto-enable/enable flows now also allowlist configured built-in channels so `channels..enabled=true` cannot remain blocked by restrictive plugin allowlists. - Plugins/Discovery: ignore scanned extension backup/disabled directory patterns (for example `.backup-*`, `.bak`, `.disabled*`) and move updater backup directories under `.openclaw-install-backups`, preventing duplicate plugin-id collisions from archived copies. - Plugins/CLI: make `openclaw plugins enable` and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl. -- Security/Voice Call: harden media stream WebSocket handling against pre-auth idle-connection DoS by adding strict pre-start timeouts, pending/per-IP connection limits, and total connection caps for streaming endpoints. This ships in the next npm release. Thanks @jiseoung for reporting. +- Security/Voice Call: harden media stream WebSocket handling against pre-auth idle-connection DoS by adding strict pre-start timeouts, pending/per-IP connection limits, and total connection caps for streaming endpoints. Thanks @jiseoung for reporting. - Security/Sessions: redact sensitive token patterns from `sessions_history` tool output and surface `contentRedacted` metadata when masking occurs. (#16928) Thanks @aether-ai-agent. -- Security/Exec: stop trusting `PATH`-derived directories for safe-bin allowlist checks, add explicit `tools.exec.safeBinTrustedDirs`, and pin safe-bin shell execution to resolved absolute executable paths to prevent binary-shadowing approval bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Elevated: match `tools.elevated.allowFrom` against sender identities only (not recipient `ctx.To`), closing a recipient-token bypass for `/elevated` authorization. This ships in the next npm release. Thanks @jiseoung for reporting. -- Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. This ships in the next npm release. Thanks @jiseoung for reporting. -- Security/Group policy: harden `channels.*.groups.*.toolsBySender` matching by requiring explicit sender-key types (`id:`, `e164:`, `username:`, `name:`), preventing cross-identifier collisions across mutable/display-name fields while keeping legacy untyped keys on a deprecated ID-only path. This ships in the next npm release. Thanks @jiseoung for reporting. +- Security/Exec: stop trusting `PATH`-derived directories for safe-bin allowlist checks, add explicit `tools.exec.safeBinTrustedDirs`, and pin safe-bin shell execution to resolved absolute executable paths to prevent binary-shadowing approval bypasses. Thanks @tdjackey for reporting. +- Security/Elevated: match `tools.elevated.allowFrom` against sender identities only (not recipient `ctx.To`), closing a recipient-token bypass for `/elevated` authorization. Thanks @jiseoung for reporting. +- Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. Thanks @jiseoung for reporting. +- Security/Group policy: harden `channels.*.groups.*.toolsBySender` matching by requiring explicit sender-key types (`id:`, `e164:`, `username:`, `name:`), preventing cross-identifier collisions across mutable/display-name fields while keeping legacy untyped keys on a deprecated ID-only path. Thanks @jiseoung for reporting. - Channels/Group policy: fail closed when `groupPolicy: "allowlist"` is set without explicit `groups`, honor account-level `groupPolicy` overrides, and enforce `groupPolicy: "disabled"` as a hard group block. (#22215) Thanks @etereo. - Telegram/Discord extensions: propagate trusted `mediaLocalRoots` through extension outbound `sendMedia` options so extension direct-send media paths honor agent-scoped local-media allowlists. (#20029, #21903, #23227) - Agents/Exec: honor explicit agent context when resolving `tools.exec` defaults for runs with opaque/non-agent session keys, so per-agent `host/security/ask` policies are applied consistently. (#11832) @@ -295,16 +295,16 @@ Docs: https://docs.openclaw.ai - Control UI: show pairing-required guidance (commands + mobile tokenized URL reminder) when the dashboard disconnects with `1008 pairing required`. - Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`). - Security/Audit: make `gateway.real_ip_fallback_enabled` severity conditional for loopback trusted-proxy setups (warn for loopback-only `trustedProxies`, critical when non-loopback proxies are trusted). (#23428) Thanks @bmendonca3. -- Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec env: block `SHELLOPTS`/`PS4` in host exec env sanitizers and restrict shell-wrapper (`bash|sh|zsh ... -c/-lc`) request env overrides to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`) on both node host and macOS companion paths, preventing xtrace prompt command-substitution allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. Thanks @tdjackey for reporting. +- Security/Exec env: block `SHELLOPTS`/`PS4` in host exec env sanitizers and restrict shell-wrapper (`bash|sh|zsh ... -c/-lc`) request env overrides to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`) on both node host and macOS companion paths, preventing xtrace prompt command-substitution allowlist bypasses. Thanks @tdjackey for reporting. - WhatsApp/Security: enforce `allowFrom` for direct-message outbound targets in all send modes (including `mode: "explicit"`), preventing sends to non-allowlisted numbers. (#20108) Thanks @zahlmann. -- Security/Exec approvals: fail closed on shell line continuations (`\\\n`/`\\\r\n`) and treat shell-wrapper execution as approval-required in allowlist mode, preventing `$\\` newline command-substitution bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: fail closed on shell line continuations (`\\\n`/`\\\r\n`) and treat shell-wrapper execution as approval-required in allowlist mode, preventing `$\\` newline command-substitution bypasses. Thanks @tdjackey for reporting. - Security/Gateway: emit a startup security warning when insecure/dangerous config flags are enabled (including `gateway.controlUi.dangerouslyDisableDeviceAuth=true`) and point operators to `openclaw security audit`. -- Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. This ships in the next npm release. Thanks @aether-ai-agent for reporting. -- Security/Exec approvals: treat `env` and shell-dispatch wrappers as transparent during allowlist analysis on node-host and macOS companion paths so policy checks match the effective executable/inline shell payload instead of the wrapper binary, blocking wrapper-smuggled allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec approvals: require explicit safe-bin profiles for `tools.exec.safeBins` entries in allowlist mode (remove generic safe-bin profile fallback), and add `tools.exec.safeBinProfiles` for safe custom binaries so unprofiled interpreter-style entries cannot be treated as stdin-safe. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. Thanks @aether-ai-agent for reporting. +- Security/Exec approvals: treat `env` and shell-dispatch wrappers as transparent during allowlist analysis on node-host and macOS companion paths so policy checks match the effective executable/inline shell payload instead of the wrapper binary, blocking wrapper-smuggled allowlist bypasses. Thanks @tdjackey for reporting. +- Security/Exec approvals: require explicit safe-bin profiles for `tools.exec.safeBins` entries in allowlist mode (remove generic safe-bin profile fallback), and add `tools.exec.safeBinProfiles` for safe custom binaries so unprofiled interpreter-style entries cannot be treated as stdin-safe. Thanks @tdjackey for reporting. - Security/Channels: harden Slack external menu token handling by switching to CSPRNG tokens, validating token shape, requiring user identity for external option lookups, and avoiding fabricated timestamp `trigger_id` fallbacks; also switch Tlon Urbit channel IDs to CSPRNG UUIDs, centralize secure ID/token generation via shared infra helpers, and add a guardrail test to block new runtime `Date.now()+Math.random()` token/id patterns. -- Security/Hooks transforms: enforce symlink-safe containment for webhook transform module paths (including `hooks.transformsDir` and `hooks.mappings[].transform.module`) by resolving existing-path ancestors via realpath before import, while preserving in-root symlink support; add regression coverage for both escape and allow cases. This ships in the next npm release. Thanks @aether-ai-agent for reporting. +- Security/Hooks transforms: enforce symlink-safe containment for webhook transform module paths (including `hooks.transformsDir` and `hooks.mappings[].transform.module`) by resolving existing-path ancestors via realpath before import, while preserving in-root symlink support; add regression coverage for both escape and allow cases. Thanks @aether-ai-agent for reporting. - Telegram/WSL2: disable `autoSelectFamily` by default on WSL2 and memoize WSL2 detection in Telegram network decision logic to avoid repeated sync `/proc/version` probes on fetch/send paths. (#21916) Thanks @MizukiMachine. - Telegram/Network: default Node 22+ DNS result ordering to `ipv4first` for Telegram fetch paths and add `OPENCLAW_TELEGRAM_DNS_RESULT_ORDER`/`channels.telegram.network.dnsResultOrder` overrides to reduce IPv6-path fetch failures. (#5405) Thanks @Glucksberg. - Telegram/Forward bursts: coalesce forwarded text+media updates through a dedicated forward lane debounce window that works with default inbound debounce config, while keeping forwarded control commands immediate. (#19476) thanks @napetrov. @@ -353,28 +353,28 @@ Docs: https://docs.openclaw.ai - Security/Audit: add `openclaw security audit` finding `gateway.nodes.allow_commands_dangerous` for risky `gateway.nodes.allowCommands` overrides, with severity upgraded to critical on remote gateway exposure. - Gateway/Control plane: reduce cross-client write limiter contention by adding `connId` fallback keying when device ID and client IP are both unavailable. - Security/Config: block prototype-key traversal during config merge patch and legacy migration merge helpers (`__proto__`, `constructor`, `prototype`) to prevent prototype pollution during config mutation flows. (#22968) Thanks @Clawborn. -- Security/Shell env: validate login-shell executable paths for shell-env fallback (`/etc/shells` + trusted prefixes), block `SHELL`/`HOME`/`ZDOTDIR` in config env ingestion before fallback execution, and sanitize fallback shell exec env to pin `HOME` to the real user home while dropping `ZDOTDIR` and other dangerous startup vars. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Shell env: validate login-shell executable paths for shell-env fallback (`/etc/shells` + trusted prefixes), block `SHELL`/`HOME`/`ZDOTDIR` in config env ingestion before fallback execution, and sanitize fallback shell exec env to pin `HOME` to the real user home while dropping `ZDOTDIR` and other dangerous startup vars. Thanks @tdjackey for reporting. - Network/SSRF: enable `autoSelectFamily` on pinned undici dispatchers (with attempt timeout) so IPv6-unreachable environments can quickly fall back to IPv4 for guarded fetch paths. (#19950) Thanks @ENAwareness. - Security/Config: make parsed chat allowlist checks fail closed when `allowFrom` is empty, restoring expected DM/pairing gating. - Security/Exec: in non-default setups that manually add `sort` to `tools.exec.safeBins`, block `sort --compress-program` so allowlist-mode safe-bin checks cannot bypass approval. Thanks @tdjackey for reporting. - Security/Exec approvals: when users choose `allow-always` for shell-wrapper commands (for example `/bin/zsh -lc ...`), persist allowlist patterns for the inner executable(s) instead of the wrapper shell binary, preventing accidental broad shell allowlisting in moderate mode. (#23276) Thanks @xrom2863. - Security/Exec: fail closed when `tools.exec.host=sandbox` is configured/requested but sandbox runtime is unavailable. (#23398) Thanks @bmendonca3. -- Security/macOS app beta: enforce path-only `system.run` allowlist matching (drop basename matches like `echo`), migrate legacy basename entries to last resolved paths when available, and harden shell-chain handling to fail closed on unsafe parse/control syntax (including quoted command substitution/backticks). This is an optional allowlist-mode feature; default installs remain deny-by-default. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Agents: auto-generate and persist a dedicated `commands.ownerDisplaySecret` when `commands.ownerDisplay=hash`, remove gateway token fallback from owner-ID prompt hashing across CLI and embedded agent runners, and centralize owner-display secret resolution in one shared helper. This ships in the next npm release. Thanks @aether-ai-agent for reporting. -- Security/SSRF: expand IPv4 fetch guard blocking to include RFC special-use/non-global ranges (including benchmarking, TEST-NET, multicast, and reserved/broadcast blocks), centralize range checks into a single CIDR policy table, and reuse one shared host/IP classifier across literal + DNS checks to reduce classifier drift. This ships in the next npm release. Thanks @princeeismond-dot for reporting. +- Security/macOS app beta: enforce path-only `system.run` allowlist matching (drop basename matches like `echo`), migrate legacy basename entries to last resolved paths when available, and harden shell-chain handling to fail closed on unsafe parse/control syntax (including quoted command substitution/backticks). This is an optional allowlist-mode feature; default installs remain deny-by-default. Thanks @tdjackey for reporting. +- Security/Agents: auto-generate and persist a dedicated `commands.ownerDisplaySecret` when `commands.ownerDisplay=hash`, remove gateway token fallback from owner-ID prompt hashing across CLI and embedded agent runners, and centralize owner-display secret resolution in one shared helper. Thanks @aether-ai-agent for reporting. +- Security/SSRF: expand IPv4 fetch guard blocking to include RFC special-use/non-global ranges (including benchmarking, TEST-NET, multicast, and reserved/broadcast blocks), centralize range checks into a single CIDR policy table, and reuse one shared host/IP classifier across literal + DNS checks to reduce classifier drift. Thanks @princeeismond-dot for reporting. - Security/SSRF: block RFC2544 benchmarking range (`198.18.0.0/15`) across direct and embedded-IP paths, and normalize IPv6 dotted-quad transition literals (for example `::127.0.0.1`, `64:ff9b::8.8.8.8`) in shared IP parsing/classification. - Security/Archive: block zip symlink escapes during archive extraction. - Security/Media sandbox: keep tmp media allowance for absolute tmp paths only and enforce symlink-escape checks before sandbox-validated reads, preventing tmp symlink exfiltration and relative `../` sandbox escapes when sandboxes live under tmp. (#17892) Thanks @dashed. - Browser/Upload: accept canonical in-root upload paths when the configured uploads directory is a symlink alias (for example `/tmp` -> `/private/tmp` on macOS), so browser upload validation no longer rejects valid files during client->server revalidation. (#23300, #23222, #22848) Thanks @bgaither4, @parkerati, and @Nabsku. - Security/Discord: add `openclaw security audit` warnings for name/tag-based Discord allowlist entries (DM allowlists, guild/channel `users`, and pairing-store entries), highlighting slug-collision risk while keeping name-based matching supported, and canonicalize resolved Discord allowlist names to IDs at runtime without rewriting config files. Thanks @tdjackey for reporting. - Security/Gateway: block node-role connections when device identity metadata is missing. -- Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. Thanks @tdjackey for reporting. - Media/Understanding: preserve `application/pdf` MIME classification during text-like file heuristics so PDF uploads use PDF extraction paths instead of being inlined as raw text. (#23191) Thanks @claudeplay2026-byte. -- Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback `index.html`. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Gateway avatars: block symlink traversal during local avatar `data:` URL resolution by enforcing realpath containment and file-identity checks before reads. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback `index.html`. Thanks @tdjackey for reporting. +- Security/Gateway avatars: block symlink traversal during local avatar `data:` URL resolution by enforcing realpath containment and file-identity checks before reads. Thanks @tdjackey for reporting. - Security/Control UI: centralize avatar URL/path validation across gateway/config helpers and enforce a 2 MB max size for local agent avatar files before `/avatar` resolution, reducing oversized-avatar memory risk without changing supported avatar formats. -- Security/Control UI avatars: harden `/avatar/:agentId` local avatar serving by rejecting symlink paths and requiring fd-level file identity + size checks before reads. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Control UI avatars: harden `/avatar/:agentId` local avatar serving by rejecting symlink paths and requiring fd-level file identity + size checks before reads. Thanks @tdjackey for reporting. +- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. Thanks @tdjackey for reporting. - Security/MSTeams media: route attachment auth-retry and Graph SharePoint download redirects through shared `safeFetch` so each hop is validated with allowlist + DNS/IP checks across the full redirect chain. (#23598) Thanks @Asm3r96 and @lewiswigmore. - Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3. - Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs. From 3c95f8966205e5173878d4ce71c0e1034c833def Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:29:43 +0000 Subject: [PATCH 318/408] refactor(exec): split system.run phases and align ts/swift validator contracts --- .../OpenClaw/ExecApprovalsSocket.swift | 116 ++++----- .../OpenClaw/ExecHostRequestEvaluator.swift | 84 +++++++ .../ExecHostRequestEvaluatorTests.swift | 76 ++++++ .../ExecSystemRunCommandValidatorTests.swift | 98 +++++--- src/infra/system-run-command.contract.test.ts | 54 ++++ src/node-host/invoke-system-run.ts | 236 ++++++++++++------ .../fixtures/system-run-command-contract.json | 75 ++++++ 7 files changed, 565 insertions(+), 174 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift create mode 100644 apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift create mode 100644 src/infra/system-run-command.contract.test.ts create mode 100644 test/fixtures/system-run-command-contract.json diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift index 16f8db913056..1417589ae4a5 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -38,7 +38,7 @@ private struct ExecHostSocketRequest: Codable { var requestJson: String } -private struct ExecHostRequest: Codable { +struct ExecHostRequest: Codable { var command: [String] var rawCommand: String? var cwd: String? @@ -59,7 +59,7 @@ private struct ExecHostRunResult: Codable { var error: String? } -private struct ExecHostError: Codable { +struct ExecHostError: Codable, Error { var code: String var message: String var reason: String? @@ -353,55 +353,28 @@ private enum ExecHostExecutor { private typealias ExecApprovalContext = ExecApprovalEvaluation static func handle(_ request: ExecHostRequest) async -> ExecHostResponse { - let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - guard !command.isEmpty else { - return self.errorResponse( - code: "INVALID_REQUEST", - message: "command required", - reason: "invalid") - } - - let validatedCommand = ExecSystemRunCommandValidator.resolve( - command: command, - rawCommand: request.rawCommand) - let displayCommand: String - switch validatedCommand { - case let .ok(resolved): - displayCommand = resolved.displayCommand - case let .invalid(message): - return self.errorResponse( - code: "INVALID_REQUEST", - message: message, - reason: "invalid") + let validatedRequest: ExecHostValidatedRequest + switch ExecHostRequestEvaluator.validateRequest(request) { + case .success(let request): + validatedRequest = request + case .failure(let error): + return self.errorResponse(error) } let context = await self.buildContext( request: request, - command: command, - rawCommand: displayCommand) - if context.security == .deny { - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DISABLED: security=deny", - reason: "security=deny") - } - - let approvalDecision = request.approvalDecision - if approvalDecision == .deny { - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DENIED: user denied", - reason: "user-denied") - } - - var approvedByAsk = approvalDecision != nil - if ExecApprovalHelpers.requiresAsk( - ask: context.ask, - security: context.security, - allowlistMatch: context.allowlistMatch, - skillAllow: context.skillAllow), - approvalDecision == nil + command: validatedRequest.command, + rawCommand: validatedRequest.displayCommand) + + switch ExecHostRequestEvaluator.evaluate( + context: context, + approvalDecision: request.approvalDecision) { + case .deny(let error): + return self.errorResponse(error) + case .allow: + break + case .requiresPrompt: let decision = ExecApprovalsPromptPresenter.prompt( ExecApprovalPromptRequest( command: context.displayCommand, @@ -413,33 +386,35 @@ private enum ExecHostExecutor { resolvedPath: context.resolution?.resolvedPath, sessionKey: request.sessionKey)) + let followupDecision: ExecApprovalDecision switch decision { case .deny: - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DENIED: user denied", - reason: "user-denied") + followupDecision = .deny case .allowAlways: - approvedByAsk = true + followupDecision = .allowAlways self.persistAllowlistEntry(decision: decision, context: context) case .allowOnce: - approvedByAsk = true + followupDecision = .allowOnce } - } - - self.persistAllowlistEntry(decision: approvalDecision, context: context) - if context.security == .allowlist, - !context.allowlistSatisfied, - !context.skillAllow, - !approvedByAsk - { - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DENIED: allowlist miss", - reason: "allowlist-miss") + switch ExecHostRequestEvaluator.evaluate( + context: context, + approvalDecision: followupDecision) + { + case .deny(let error): + return self.errorResponse(error) + case .allow: + break + case .requiresPrompt: + return self.errorResponse( + code: "INVALID_REQUEST", + message: "unexpected approval state", + reason: "invalid") + } } + self.persistAllowlistEntry(decision: request.approvalDecision, context: context) + if context.allowlistSatisfied { var seenPatterns = Set() for (idx, match) in context.allowlistMatches.enumerated() { @@ -462,7 +437,7 @@ private enum ExecHostExecutor { } return await self.runCommand( - command: command, + command: validatedRequest.command, cwd: request.cwd, env: context.env, timeoutMs: request.timeoutMs) @@ -535,6 +510,17 @@ private enum ExecHostExecutor { return self.successResponse(payload) } + private static func errorResponse( + _ error: ExecHostError) -> ExecHostResponse + { + ExecHostResponse( + type: "response", + id: UUID().uuidString, + ok: false, + payload: nil, + error: error) + } + private static func errorResponse( code: String, message: String, diff --git a/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift b/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift new file mode 100644 index 000000000000..fe38d7ea18f2 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift @@ -0,0 +1,84 @@ +import Foundation + +struct ExecHostValidatedRequest { + let command: [String] + let displayCommand: String +} + +enum ExecHostPolicyDecision { + case deny(ExecHostError) + case requiresPrompt + case allow(approvedByAsk: Bool) +} + +enum ExecHostRequestEvaluator { + static func validateRequest(_ request: ExecHostRequest) -> Result { + let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !command.isEmpty else { + return .failure( + ExecHostError( + code: "INVALID_REQUEST", + message: "command required", + reason: "invalid")) + } + + let validatedCommand = ExecSystemRunCommandValidator.resolve( + command: command, + rawCommand: request.rawCommand) + switch validatedCommand { + case .ok(let resolved): + return .success(ExecHostValidatedRequest(command: command, displayCommand: resolved.displayCommand)) + case .invalid(let message): + return .failure( + ExecHostError( + code: "INVALID_REQUEST", + message: message, + reason: "invalid")) + } + } + + static func evaluate( + context: ExecApprovalEvaluation, + approvalDecision: ExecApprovalDecision?) -> ExecHostPolicyDecision + { + if context.security == .deny { + return .deny( + ExecHostError( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DISABLED: security=deny", + reason: "security=deny")) + } + + if approvalDecision == .deny { + return .deny( + ExecHostError( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DENIED: user denied", + reason: "user-denied")) + } + + let approvedByAsk = approvalDecision != nil + let requiresPrompt = ExecApprovalHelpers.requiresAsk( + ask: context.ask, + security: context.security, + allowlistMatch: context.allowlistMatch, + skillAllow: context.skillAllow) && approvalDecision == nil + if requiresPrompt { + return .requiresPrompt + } + + if context.security == .allowlist, + !context.allowlistSatisfied, + !context.skillAllow, + !approvedByAsk + { + return .deny( + ExecHostError( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DENIED: allowlist miss", + reason: "allowlist-miss")) + } + + return .allow(approvedByAsk: approvedByAsk) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift new file mode 100644 index 000000000000..64ef6a21edad --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import OpenClaw + +struct ExecHostRequestEvaluatorTests { + @Test func validateRequestRejectsEmptyCommand() { + let request = ExecHostRequest(command: [], rawCommand: nil, cwd: nil, env: nil, timeoutMs: nil, needsScreenRecording: nil, agentId: nil, sessionKey: nil, approvalDecision: nil) + switch ExecHostRequestEvaluator.validateRequest(request) { + case .success: + Issue.record("expected invalid request") + case .failure(let error): + #expect(error.code == "INVALID_REQUEST") + #expect(error.message == "command required") + } + } + + @Test func evaluateRequiresPromptOnAllowlistMissWithoutDecision() { + let context = Self.makeContext(security: .allowlist, ask: .onMiss, allowlistSatisfied: false, skillAllow: false) + let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: nil) + switch decision { + case .requiresPrompt: + break + case .allow: + Issue.record("expected prompt requirement") + case .deny(let error): + Issue.record("unexpected deny: \(error.message)") + } + } + + @Test func evaluateAllowsAllowOnceDecisionOnAllowlistMiss() { + let context = Self.makeContext(security: .allowlist, ask: .onMiss, allowlistSatisfied: false, skillAllow: false) + let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: .allowOnce) + switch decision { + case .allow(let approvedByAsk): + #expect(approvedByAsk) + case .requiresPrompt: + Issue.record("expected allow decision") + case .deny(let error): + Issue.record("unexpected deny: \(error.message)") + } + } + + @Test func evaluateDeniesOnExplicitDenyDecision() { + let context = Self.makeContext(security: .full, ask: .off, allowlistSatisfied: true, skillAllow: false) + let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: .deny) + switch decision { + case .deny(let error): + #expect(error.reason == "user-denied") + case .requiresPrompt: + Issue.record("expected deny decision") + case .allow: + Issue.record("expected deny decision") + } + } + + private static func makeContext( + security: ExecSecurity, + ask: ExecAsk, + allowlistSatisfied: Bool, + skillAllow: Bool) -> ExecApprovalEvaluation + { + ExecApprovalEvaluation( + command: ["/usr/bin/echo", "hi"], + displayCommand: "/usr/bin/echo hi", + agentId: nil, + security: security, + ask: ask, + env: [:], + resolution: nil, + allowlistResolutions: [], + allowlistMatches: [], + allowlistSatisfied: allowlistSatisfied, + allowlistMatch: nil, + skillAllow: skillAllow) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift index c6ad3319a65e..ed3773a44ed6 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift @@ -2,49 +2,75 @@ import Foundation import Testing @testable import OpenClaw +private struct SystemRunCommandContractFixture: Decodable { + let cases: [SystemRunCommandContractCase] +} + +private struct SystemRunCommandContractCase: Decodable { + let name: String + let command: [String] + let rawCommand: String? + let expected: SystemRunCommandContractExpected +} + +private struct SystemRunCommandContractExpected: Decodable { + let valid: Bool + let displayCommand: String? + let errorContains: String? +} + struct ExecSystemRunCommandValidatorTests { - @Test func rejectsPayloadOnlyRawForPositionalCarrierWrappers() { - let command = ["/bin/sh", "-lc", #"$0 "$1""#, "/usr/bin/touch", "/tmp/marker"] - let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: #"$0 "$1""#) - switch result { - case .ok: - Issue.record("expected rawCommand mismatch") - case .invalid(let message): - #expect(message.contains("rawCommand does not match command")) - } - } + @Test func matchesSharedSystemRunCommandContractFixture() throws { + for entry in try Self.loadContractCases() { + let result = ExecSystemRunCommandValidator.resolve(command: entry.command, rawCommand: entry.rawCommand) - @Test func acceptsCanonicalDisplayForPositionalCarrierWrappers() { - let command = ["/bin/sh", "-lc", #"$0 "$1""#, "/usr/bin/touch", "/tmp/marker"] - let expected = ExecCommandFormatter.displayString(for: command) - let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: expected) - switch result { - case .ok(let resolved): - #expect(resolved.displayCommand == expected) - case .invalid(let message): - Issue.record("unexpected validation failure: \(message)") + if !entry.expected.valid { + switch result { + case .ok(let resolved): + Issue.record("\(entry.name): expected invalid result, got displayCommand=\(resolved.displayCommand)") + case .invalid(let message): + if let expected = entry.expected.errorContains { + #expect( + message.contains(expected), + "\(entry.name): expected error containing \(expected), got \(message)") + } + } + continue + } + + switch result { + case .ok(let resolved): + #expect( + resolved.displayCommand == entry.expected.displayCommand, + "\(entry.name): unexpected display command") + case .invalid(let message): + Issue.record("\(entry.name): unexpected invalid result: \(message)") + } } } - @Test func acceptsShellPayloadRawForTransparentEnvWrapper() { - let command = ["/usr/bin/env", "bash", "-lc", "echo hi"] - let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: "echo hi") - switch result { - case .ok(let resolved): - #expect(resolved.displayCommand == "echo hi") - case .invalid(let message): - Issue.record("unexpected validation failure: \(message)") - } + private static func loadContractCases() throws -> [SystemRunCommandContractCase] { + let fixtureURL = try self.findContractFixtureURL() + let data = try Data(contentsOf: fixtureURL) + let decoded = try JSONDecoder().decode(SystemRunCommandContractFixture.self, from: data) + return decoded.cases } - @Test func rejectsShellPayloadRawForEnvModifierPrelude() { - let command = ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"] - let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: "echo hi") - switch result { - case .ok: - Issue.record("expected rawCommand mismatch") - case .invalid(let message): - #expect(message.contains("rawCommand does not match command")) + private static func findContractFixtureURL() throws -> URL { + var cursor = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0..<8 { + let candidate = cursor + .appendingPathComponent("test") + .appendingPathComponent("fixtures") + .appendingPathComponent("system-run-command-contract.json") + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + cursor.deleteLastPathComponent() } + throw NSError( + domain: "ExecSystemRunCommandValidatorTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "missing shared system-run command contract fixture"]) } } diff --git a/src/infra/system-run-command.contract.test.ts b/src/infra/system-run-command.contract.test.ts new file mode 100644 index 000000000000..a0555355d42c --- /dev/null +++ b/src/infra/system-run-command.contract.test.ts @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; +import { resolveSystemRunCommand } from "./system-run-command.js"; + +type ContractFixture = { + cases: ContractCase[]; +}; + +type ContractCase = { + name: string; + command: string[]; + rawCommand?: string; + expected: { + valid: boolean; + displayCommand?: string; + errorContains?: string; + }; +}; + +const fixturePath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../test/fixtures/system-run-command-contract.json", +); +const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")) as ContractFixture; + +describe("system-run command contract fixtures", () => { + for (const entry of fixture.cases) { + test(entry.name, () => { + const result = resolveSystemRunCommand({ + command: entry.command, + rawCommand: entry.rawCommand, + }); + + if (!entry.expected.valid) { + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected validation failure"); + } + if (entry.expected.errorContains) { + expect(result.message).toContain(entry.expected.errorContains); + } + return; + } + + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error(`unexpected validation failure: ${result.message}`); + } + expect(result.cmdText).toBe(entry.expected.displayCommand); + }); + } +}); diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index c9b107a5d313..39e6766f7d55 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -55,6 +55,37 @@ type SystemRunAllowlistAnalysis = { segments: ExecCommandSegment[]; }; +type ResolvedExecApprovals = ReturnType; + +type SystemRunParsePhase = { + argv: string[]; + shellCommand: string | null; + cmdText: string; + agentId: string | undefined; + sessionKey: string; + runId: string; + execution: SystemRunExecutionContext; + approvalDecision: ReturnType; + envOverrides: Record | undefined; + env: Record | undefined; + cwd: string | undefined; + timeoutMs: number | undefined; + needsScreenRecording: boolean; + approved: boolean; +}; + +type SystemRunPolicyPhase = SystemRunParsePhase & { + approvals: ResolvedExecApprovals; + security: ExecSecurity; + policy: ReturnType; + allowlistMatches: ExecAllowlistEntry[]; + analysisOk: boolean; + allowlistSatisfied: boolean; + segments: ExecCommandSegment[]; + plannedAllowlistArgv: string[] | undefined; + isWindows: boolean; +}; + const safeBinTrustedDirWarningCache = new Set(); function warnWritableTrustedDirOnce(message: string): void { @@ -270,7 +301,9 @@ function applyOutputTruncation(result: RunResult) { export { formatSystemRunAllowlistMissMessage } from "./exec-policy.js"; -export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): Promise { +async function parseSystemRunPhase( + opts: HandleSystemRunInvokeOptions, +): Promise { const command = resolveSystemRunCommand({ command: opts.params.command, rawCommand: opts.params.rawCommand, @@ -280,42 +313,62 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): ok: false, error: { code: "INVALID_REQUEST", message: command.message }, }); - return; + return null; } if (command.argv.length === 0) { await opts.sendInvokeResult({ ok: false, error: { code: "INVALID_REQUEST", message: "command required" }, }); - return; + return null; } - const argv = command.argv; const shellCommand = command.shellCommand; const cmdText = command.cmdText; const agentId = opts.params.agentId?.trim() || undefined; + const sessionKey = opts.params.sessionKey?.trim() || "node"; + const runId = opts.params.runId?.trim() || crypto.randomUUID(); + const envOverrides = sanitizeSystemRunEnvOverrides({ + overrides: opts.params.env ?? undefined, + shellWrapper: shellCommand !== null, + }); + return { + argv: command.argv, + shellCommand, + cmdText, + agentId, + sessionKey, + runId, + execution: { sessionKey, runId, cmdText }, + approvalDecision: resolveExecApprovalDecision(opts.params.approvalDecision), + envOverrides, + env: opts.sanitizeEnv(envOverrides), + cwd: opts.params.cwd?.trim() || undefined, + timeoutMs: opts.params.timeoutMs ?? undefined, + needsScreenRecording: opts.params.needsScreenRecording === true, + approved: opts.params.approved === true, + }; +} + +async function evaluateSystemRunPolicyPhase( + opts: HandleSystemRunInvokeOptions, + parsed: SystemRunParsePhase, +): Promise { const cfg = loadConfig(); - const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined; + const agentExec = parsed.agentId + ? resolveAgentConfig(cfg, parsed.agentId)?.tools?.exec + : undefined; const configuredSecurity = opts.resolveExecSecurity( agentExec?.security ?? cfg.tools?.exec?.security, ); const configuredAsk = opts.resolveExecAsk(agentExec?.ask ?? cfg.tools?.exec?.ask); - const approvals = resolveExecApprovals(agentId, { + const approvals = resolveExecApprovals(parsed.agentId, { security: configuredSecurity, ask: configuredAsk, }); const security = approvals.agent.security; const ask = approvals.agent.ask; const autoAllowSkills = approvals.agent.autoAllowSkills; - const sessionKey = opts.params.sessionKey?.trim() || "node"; - const runId = opts.params.runId?.trim() || crypto.randomUUID(); - const execution: SystemRunExecutionContext = { sessionKey, runId, cmdText }; - const approvalDecision = resolveExecApprovalDecision(opts.params.approvalDecision); - const envOverrides = sanitizeSystemRunEnvOverrides({ - overrides: opts.params.env ?? undefined, - shellWrapper: shellCommand !== null, - }); - const env = opts.sanitizeEnv(envOverrides); const { safeBins, safeBinProfiles, trustedSafeBinDirs } = resolveExecSafeBinRuntimePolicy({ global: cfg.tools?.exec, local: agentExec, @@ -323,99 +376,124 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): }); const bins = autoAllowSkills ? await opts.skillBins.current() : []; let { analysisOk, allowlistMatches, allowlistSatisfied, segments } = evaluateSystemRunAllowlist({ - shellCommand, - argv, + shellCommand: parsed.shellCommand, + argv: parsed.argv, approvals, security, safeBins, safeBinProfiles, trustedSafeBinDirs, - cwd: opts.params.cwd ?? undefined, - env, + cwd: parsed.cwd, + env: parsed.env, skillBins: bins, autoAllowSkills, }); const isWindows = process.platform === "win32"; - const cmdInvocation = shellCommand + const cmdInvocation = parsed.shellCommand ? opts.isCmdExeInvocation(segments[0]?.argv ?? []) - : opts.isCmdExeInvocation(argv); + : opts.isCmdExeInvocation(parsed.argv); const policy = evaluateSystemRunPolicy({ security, ask, analysisOk, allowlistSatisfied, - approvalDecision, - approved: opts.params.approved === true, + approvalDecision: parsed.approvalDecision, + approved: parsed.approved, isWindows, cmdInvocation, - shellWrapperInvocation: shellCommand !== null, + shellWrapperInvocation: parsed.shellCommand !== null, }); analysisOk = policy.analysisOk; allowlistSatisfied = policy.allowlistSatisfied; if (!policy.allowed) { - await sendSystemRunDenied(opts, execution, { + await sendSystemRunDenied(opts, parsed.execution, { reason: policy.eventReason, message: policy.errorMessage, }); - return; + return null; } // Fail closed if policy/runtime drift re-allows unapproved shell wrappers. - if (security === "allowlist" && shellCommand && !policy.approvedByAsk) { - await sendSystemRunDenied(opts, execution, { + if (security === "allowlist" && parsed.shellCommand && !policy.approvedByAsk) { + await sendSystemRunDenied(opts, parsed.execution, { reason: "approval-required", message: "SYSTEM_RUN_DENIED: approval required", }); - return; + return null; } const plannedAllowlistArgv = resolvePlannedAllowlistArgv({ security, - shellCommand, + shellCommand: parsed.shellCommand, policy, segments, }); if (plannedAllowlistArgv === null) { - await sendSystemRunDenied(opts, execution, { + await sendSystemRunDenied(opts, parsed.execution, { reason: "execution-plan-miss", message: "SYSTEM_RUN_DENIED: execution plan mismatch", }); - return; + return null; } + return { + ...parsed, + approvals, + security, + policy, + allowlistMatches, + analysisOk, + allowlistSatisfied, + segments, + plannedAllowlistArgv: plannedAllowlistArgv ?? undefined, + isWindows, + }; +} +async function executeSystemRunPhase( + opts: HandleSystemRunInvokeOptions, + phase: SystemRunPolicyPhase, +): Promise { const useMacAppExec = opts.preferMacAppExecHost; if (useMacAppExec) { const execRequest: ExecHostRequest = { - command: plannedAllowlistArgv ?? argv, + command: phase.plannedAllowlistArgv ?? phase.argv, // Forward canonical display text so companion approval/prompt surfaces bind to // the exact command context already validated on the node-host. - rawCommand: cmdText || null, - cwd: opts.params.cwd ?? null, - env: envOverrides ?? null, - timeoutMs: opts.params.timeoutMs ?? null, - needsScreenRecording: opts.params.needsScreenRecording ?? null, - agentId: agentId ?? null, - sessionKey: sessionKey ?? null, - approvalDecision, + rawCommand: phase.cmdText || null, + cwd: phase.cwd ?? null, + env: phase.envOverrides ?? null, + timeoutMs: phase.timeoutMs ?? null, + needsScreenRecording: phase.needsScreenRecording, + agentId: phase.agentId ?? null, + sessionKey: phase.sessionKey ?? null, + approvalDecision: phase.approvalDecision, }; - const response = await opts.runViaMacAppExecHost({ approvals, request: execRequest }); + const response = await opts.runViaMacAppExecHost({ + approvals: phase.approvals, + request: execRequest, + }); if (!response) { if (opts.execHostEnforced || !opts.execHostFallbackAllowed) { - await sendSystemRunDenied(opts, execution, { + await sendSystemRunDenied(opts, phase.execution, { reason: "companion-unavailable", message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable", }); return; } } else if (!response.ok) { - await sendSystemRunDenied(opts, execution, { + await sendSystemRunDenied(opts, phase.execution, { reason: normalizeDeniedReason(response.error.reason), message: response.error.message, }); return; } else { const result: ExecHostRunResult = response.payload; - await opts.sendExecFinishedEvent({ sessionKey, runId, cmdText, result }); + await opts.sendExecFinishedEvent({ + sessionKey: phase.sessionKey, + runId: phase.runId, + cmdText: phase.cmdText, + result, + }); await opts.sendInvokeResult({ ok: true, payloadJSON: JSON.stringify(result), @@ -424,41 +502,41 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): } } - if (policy.approvalDecision === "allow-always" && security === "allowlist") { - if (policy.analysisOk) { + if (phase.policy.approvalDecision === "allow-always" && phase.security === "allowlist") { + if (phase.policy.analysisOk) { const patterns = resolveAllowAlwaysPatterns({ - segments, - cwd: opts.params.cwd ?? undefined, - env, + segments: phase.segments, + cwd: phase.cwd, + env: phase.env, platform: process.platform, }); for (const pattern of patterns) { if (pattern) { - addAllowlistEntry(approvals.file, agentId, pattern); + addAllowlistEntry(phase.approvals.file, phase.agentId, pattern); } } } } - if (allowlistMatches.length > 0) { + if (phase.allowlistMatches.length > 0) { const seen = new Set(); - for (const match of allowlistMatches) { + for (const match of phase.allowlistMatches) { if (!match?.pattern || seen.has(match.pattern)) { continue; } seen.add(match.pattern); recordAllowlistUse( - approvals.file, - agentId, + phase.approvals.file, + phase.agentId, match, - cmdText, - segments[0]?.resolution?.resolvedPath, + phase.cmdText, + phase.segments[0]?.resolution?.resolvedPath, ); } } - if (opts.params.needsScreenRecording === true) { - await sendSystemRunDenied(opts, execution, { + if (phase.needsScreenRecording) { + await sendSystemRunDenied(opts, phase.execution, { reason: "permission:screenRecording", message: "PERMISSION_MISSING: screenRecording", }); @@ -466,23 +544,23 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): } const execArgv = resolveSystemRunExecArgv({ - plannedAllowlistArgv: plannedAllowlistArgv ?? undefined, - argv, - security, - isWindows, - policy, - shellCommand, - segments, + plannedAllowlistArgv: phase.plannedAllowlistArgv, + argv: phase.argv, + security: phase.security, + isWindows: phase.isWindows, + policy: phase.policy, + shellCommand: phase.shellCommand, + segments: phase.segments, }); - const result = await opts.runCommand( - execArgv, - opts.params.cwd?.trim() || undefined, - env, - opts.params.timeoutMs ?? undefined, - ); + const result = await opts.runCommand(execArgv, phase.cwd, phase.env, phase.timeoutMs); applyOutputTruncation(result); - await opts.sendExecFinishedEvent({ sessionKey, runId, cmdText, result }); + await opts.sendExecFinishedEvent({ + sessionKey: phase.sessionKey, + runId: phase.runId, + cmdText: phase.cmdText, + result, + }); await opts.sendInvokeResult({ ok: true, @@ -496,3 +574,15 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): }), }); } + +export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): Promise { + const parsed = await parseSystemRunPhase(opts); + if (!parsed) { + return; + } + const policyPhase = await evaluateSystemRunPolicyPhase(opts, parsed); + if (!policyPhase) { + return; + } + await executeSystemRunPhase(opts, policyPhase); +} diff --git a/test/fixtures/system-run-command-contract.json b/test/fixtures/system-run-command-contract.json new file mode 100644 index 000000000000..60b76bf1bf48 --- /dev/null +++ b/test/fixtures/system-run-command-contract.json @@ -0,0 +1,75 @@ +{ + "cases": [ + { + "name": "direct argv infers display command", + "command": ["echo", "hi there"], + "expected": { + "valid": true, + "displayCommand": "echo \"hi there\"" + } + }, + { + "name": "direct argv rejects mismatched raw command", + "command": ["uname", "-a"], + "rawCommand": "echo hi", + "expected": { + "valid": false, + "errorContains": "rawCommand does not match command" + } + }, + { + "name": "shell wrapper accepts shell payload raw command when no positional argv carriers", + "command": ["/bin/sh", "-lc", "echo hi"], + "rawCommand": "echo hi", + "expected": { + "valid": true, + "displayCommand": "echo hi" + } + }, + { + "name": "shell wrapper positional argv carrier requires full argv display binding", + "command": ["/bin/sh", "-lc", "$0 \"$1\"", "/usr/bin/touch", "/tmp/marker"], + "rawCommand": "$0 \"$1\"", + "expected": { + "valid": false, + "errorContains": "rawCommand does not match command" + } + }, + { + "name": "shell wrapper positional argv carrier accepts canonical full argv raw command", + "command": ["/bin/sh", "-lc", "$0 \"$1\"", "/usr/bin/touch", "/tmp/marker"], + "rawCommand": "/bin/sh -lc \"$0 \\\"$1\\\"\" /usr/bin/touch /tmp/marker", + "expected": { + "valid": true, + "displayCommand": "/bin/sh -lc \"$0 \\\"$1\\\"\" /usr/bin/touch /tmp/marker" + } + }, + { + "name": "env wrapper shell payload accepted when prelude has no env modifiers", + "command": ["/usr/bin/env", "bash", "-lc", "echo hi"], + "rawCommand": "echo hi", + "expected": { + "valid": true, + "displayCommand": "echo hi" + } + }, + { + "name": "env assignment prelude requires full argv display binding", + "command": ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"], + "rawCommand": "echo hi", + "expected": { + "valid": false, + "errorContains": "rawCommand does not match command" + } + }, + { + "name": "env assignment prelude accepts canonical full argv raw command", + "command": ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"], + "rawCommand": "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo hi\"", + "expected": { + "valid": true, + "displayCommand": "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo hi\"" + } + } + ] +} From 7455ceecf88803be2e12c76937da301d7a964e80 Mon Sep 17 00:00:00 2001 From: shenghui kevin Date: Tue, 24 Feb 2026 10:58:39 -0800 Subject: [PATCH 319/408] fix(windows): skip unreliable dev comparison in fs-safe openVerifiedLocalFile On Windows, device IDs (dev) returned by handle.stat() and fs.lstat() may differ even for the same file, causing false-positive 'path-mismatch' errors when reading local media files. This fix introduces a statsMatch() helper that: - Always compares inode (ino) values - Skips device ID (dev) comparison on Windows where it's unreliable - Maintains full comparison on Unix platforms Fixes #25699 --- src/infra/fs-safe.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index 7b6c648ee702..49604548a815 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -40,6 +40,23 @@ const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants. const ensureTrailingSep = (value: string) => (value.endsWith(path.sep) ? value : value + path.sep); +/** + * Compare file stats for identity verification. + * On Windows, device IDs (dev) are unreliable and may differ between + * handle.stat() and fs.lstat() for the same file. We skip dev comparison + * on Windows and rely solely on inode (ino) matching. + */ +function statsMatch(stat1: Stats, stat2: Stats): boolean { + if (stat1.ino !== stat2.ino) { + return false; + } + // On Windows, dev values are unreliable across different stat sources + if (process.platform !== "win32" && stat1.dev !== stat2.dev) { + return false; + } + return true; +} + async function openVerifiedLocalFile(filePath: string): Promise { let handle: FileHandle; try { @@ -62,13 +79,13 @@ async function openVerifiedLocalFile(filePath: string): Promise if (!stat.isFile()) { throw new SafeOpenError("not-file", "not a file"); } - if (stat.ino !== lstat.ino || stat.dev !== lstat.dev) { + if (!statsMatch(stat, lstat)) { throw new SafeOpenError("path-mismatch", "path changed during read"); } const realPath = await fs.realpath(filePath); const realStat = await fs.stat(realPath); - if (stat.ino !== realStat.ino || stat.dev !== realStat.dev) { + if (!statsMatch(stat, realStat)) { throw new SafeOpenError("path-mismatch", "path mismatch"); } From 943b8f171a78fda44ba065dcec29da27b8c072c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:32:30 +0000 Subject: [PATCH 320/408] fix: align windows safe-open file identity checks --- CHANGELOG.md | 1 + src/infra/file-identity.test.ts | 33 +++++++++++++++++++++++++++++++++ src/infra/file-identity.ts | 25 +++++++++++++++++++++++++ src/infra/fs-safe.ts | 22 +++------------------- src/infra/safe-open-sync.ts | 8 ++------ 5 files changed, 64 insertions(+), 25 deletions(-) create mode 100644 src/infra/file-identity.test.ts create mode 100644 src/infra/file-identity.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd6ca5d44a8..29963ba844bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 `dev=0` stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false `Local media path is not safe to read` drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng. - macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos. - macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl. - macOS/Voice wake routing: default forwarded voice-wake transcripts to the `webchat` channel (instead of ambiguous `last` routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18. diff --git a/src/infra/file-identity.test.ts b/src/infra/file-identity.test.ts new file mode 100644 index 000000000000..12b3029cda12 --- /dev/null +++ b/src/infra/file-identity.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { sameFileIdentity, type FileIdentityStat } from "./file-identity.js"; + +function stat(dev: number | bigint, ino: number | bigint): FileIdentityStat { + return { dev, ino }; +} + +describe("sameFileIdentity", () => { + it("accepts exact dev+ino match", () => { + expect(sameFileIdentity(stat(7, 11), stat(7, 11), "linux")).toBe(true); + }); + + it("rejects inode mismatch", () => { + expect(sameFileIdentity(stat(7, 11), stat(7, 12), "linux")).toBe(false); + }); + + it("rejects dev mismatch on non-windows", () => { + expect(sameFileIdentity(stat(7, 11), stat(8, 11), "linux")).toBe(false); + }); + + it("accepts win32 dev mismatch when either side is 0", () => { + expect(sameFileIdentity(stat(0, 11), stat(8, 11), "win32")).toBe(true); + expect(sameFileIdentity(stat(7, 11), stat(0, 11), "win32")).toBe(true); + }); + + it("keeps dev strictness on win32 when both dev values are non-zero", () => { + expect(sameFileIdentity(stat(7, 11), stat(8, 11), "win32")).toBe(false); + }); + + it("handles bigint stats", () => { + expect(sameFileIdentity(stat(0n, 11n), stat(8n, 11n), "win32")).toBe(true); + }); +}); diff --git a/src/infra/file-identity.ts b/src/infra/file-identity.ts new file mode 100644 index 000000000000..686d6dd086e9 --- /dev/null +++ b/src/infra/file-identity.ts @@ -0,0 +1,25 @@ +export type FileIdentityStat = { + dev: number | bigint; + ino: number | bigint; +}; + +function isZero(value: number | bigint): boolean { + return value === 0 || value === 0n; +} + +export function sameFileIdentity( + left: FileIdentityStat, + right: FileIdentityStat, + platform: NodeJS.Platform = process.platform, +): boolean { + if (left.ino !== right.ino) { + return false; + } + + // On Windows, path-based stat calls can report dev=0 while fd-based stat + // reports a real volume serial; treat either-side dev=0 as "unknown device". + if (left.dev === right.dev) { + return true; + } + return platform === "win32" && (isZero(left.dev) || isZero(right.dev)); +} diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index 49604548a815..b42a109df989 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs"; import type { FileHandle } from "node:fs/promises"; import fs from "node:fs/promises"; import path from "node:path"; +import { sameFileIdentity } from "./file-identity.js"; import { isNotFoundPathError, isPathInside, isSymlinkOpenError } from "./path-guards.js"; export type SafeOpenErrorCode = @@ -40,23 +41,6 @@ const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants. const ensureTrailingSep = (value: string) => (value.endsWith(path.sep) ? value : value + path.sep); -/** - * Compare file stats for identity verification. - * On Windows, device IDs (dev) are unreliable and may differ between - * handle.stat() and fs.lstat() for the same file. We skip dev comparison - * on Windows and rely solely on inode (ino) matching. - */ -function statsMatch(stat1: Stats, stat2: Stats): boolean { - if (stat1.ino !== stat2.ino) { - return false; - } - // On Windows, dev values are unreliable across different stat sources - if (process.platform !== "win32" && stat1.dev !== stat2.dev) { - return false; - } - return true; -} - async function openVerifiedLocalFile(filePath: string): Promise { let handle: FileHandle; try { @@ -79,13 +63,13 @@ async function openVerifiedLocalFile(filePath: string): Promise if (!stat.isFile()) { throw new SafeOpenError("not-file", "not a file"); } - if (!statsMatch(stat, lstat)) { + if (!sameFileIdentity(stat, lstat)) { throw new SafeOpenError("path-mismatch", "path changed during read"); } const realPath = await fs.realpath(filePath); const realStat = await fs.stat(realPath); - if (!statsMatch(stat, realStat)) { + if (!sameFileIdentity(stat, realStat)) { throw new SafeOpenError("path-mismatch", "path mismatch"); } diff --git a/src/infra/safe-open-sync.ts b/src/infra/safe-open-sync.ts index f2dbdfb703b1..311849ba9fdb 100644 --- a/src/infra/safe-open-sync.ts +++ b/src/infra/safe-open-sync.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { sameFileIdentity as hasSameFileIdentity } from "./file-identity.js"; export type SafeOpenSyncFailureReason = "path" | "validation" | "io"; @@ -17,12 +18,7 @@ function isExpectedPathError(error: unknown): boolean { } export function sameFileIdentity(left: fs.Stats, right: fs.Stats): boolean { - // On Windows, lstatSync (by path) may return dev=0 while fstatSync (by fd) - // returns the real volume serial number. When either dev is 0, fall back to - // ino-only comparison which is still unique within a single volume. - const devMatch = - left.dev === right.dev || (process.platform === "win32" && (left.dev === 0 || right.dev === 0)); - return devMatch && left.ino === right.ino; + return hasSameFileIdentity(left, right); } export function openVerifiedFileSync(params: { From a9ce6bd79b73139eee9716742c8fade09f1b4468 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:43:19 +0000 Subject: [PATCH 321/408] refactor: dedupe exec wrapper denial plan and test setup --- src/infra/exec-approvals.test.ts | 14 +- src/infra/exec-wrapper-resolution.ts | 28 ++- src/node-host/invoke-system-run.test.ts | 223 ++++++++++++------------ 3 files changed, 140 insertions(+), 125 deletions(-) diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 9ce901a84820..39ee8b3f3edb 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -24,6 +24,14 @@ import { type ExecAllowlistEntry, } from "./exec-approvals.js"; +function buildNestedEnvShellCommand(params: { + envExecutable: string; + depth: number; + payload: string; +}): string[] { + return [...Array(params.depth).fill(params.envExecutable), "/bin/sh", "-c", params.payload]; +} + describe("exec approvals allowlist matching", () => { const baseResolution = { rawExecutable: "rg", @@ -311,7 +319,11 @@ describe("exec approvals command resolution", () => { fs.chmodSync(envPath, 0o755); const analysis = analyzeArgvCommand({ - argv: [envPath, envPath, envPath, envPath, envPath, "/bin/sh", "-c", "echo pwned"], + argv: buildNestedEnvShellCommand({ + envExecutable: envPath, + depth: 5, + payload: "echo pwned", + }), cwd: dir, env: makePathEnv(binDir), }); diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts index 6d09029da05c..1f91c3b4a1fc 100644 --- a/src/infra/exec-wrapper-resolution.ts +++ b/src/infra/exec-wrapper-resolution.ts @@ -448,6 +448,19 @@ function isSemanticDispatchWrapperUsage(wrapper: string, argv: string[]): boolea return !TRANSPARENT_DISPATCH_WRAPPERS.has(wrapper); } +function blockedDispatchWrapperPlan(params: { + argv: string[]; + wrappers: string[]; + blockedWrapper: string; +}): DispatchWrapperExecutionPlan { + return { + argv: params.argv, + wrappers: params.wrappers, + policyBlocked: true, + blockedWrapper: params.blockedWrapper, + }; +} + export function resolveDispatchWrapperExecutionPlan( argv: string[], maxDepth = MAX_DISPATCH_WRAPPER_DEPTH, @@ -457,36 +470,33 @@ export function resolveDispatchWrapperExecutionPlan( for (let depth = 0; depth < maxDepth; depth += 1) { const unwrap = unwrapKnownDispatchWrapperInvocation(current); if (unwrap.kind === "blocked") { - return { + return blockedDispatchWrapperPlan({ argv: current, wrappers, - policyBlocked: true, blockedWrapper: unwrap.wrapper, - }; + }); } if (unwrap.kind !== "unwrapped" || unwrap.argv.length === 0) { break; } wrappers.push(unwrap.wrapper); if (isSemanticDispatchWrapperUsage(unwrap.wrapper, current)) { - return { + return blockedDispatchWrapperPlan({ argv: current, wrappers, - policyBlocked: true, blockedWrapper: unwrap.wrapper, - }; + }); } current = unwrap.argv; } if (wrappers.length >= maxDepth) { const overflow = unwrapKnownDispatchWrapperInvocation(current); if (overflow.kind === "blocked" || overflow.kind === "unwrapped") { - return { + return blockedDispatchWrapperPlan({ argv: current, wrappers, - policyBlocked: true, blockedWrapper: overflow.wrapper, - }; + }); } } return { argv: current, wrappers, policyBlocked: false }; diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 675f108b2ee3..2d939c7726ea 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -21,6 +21,30 @@ describe("formatSystemRunAllowlistMissMessage", () => { }); describe("handleSystemRunInvoke mac app exec host routing", () => { + function buildNestedEnvShellCommand(params: { depth: number; payload: string }): string[] { + return [...Array(params.depth).fill("/usr/bin/env"), "/bin/sh", "-c", params.payload]; + } + + async function withTempApprovalsHome(params: { + approvals: Parameters[0]; + run: (ctx: { tempHome: string }) => Promise; + }): Promise { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-approvals-")); + const previousOpenClawHome = process.env.OPENCLAW_HOME; + process.env.OPENCLAW_HOME = tempHome; + saveExecApprovals(params.approvals); + try { + return await params.run({ tempHome }); + } finally { + if (previousOpenClawHome === undefined) { + delete process.env.OPENCLAW_HOME; + } else { + process.env.OPENCLAW_HOME = previousOpenClawHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + } + async function runSystemInvoke(params: { preferMacAppExecHost: boolean; runViaResponse?: ExecHostResponse | null; @@ -254,22 +278,6 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }); it("denies ./skill-bin even when autoAllowSkills trust entry exists", async () => { - const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-skill-path-spoof-")); - const previousOpenClawHome = process.env.OPENCLAW_HOME; - const skillBinPath = path.join(tempHome, "skill-bin"); - fs.writeFileSync(skillBinPath, "#!/bin/sh\necho should-not-run\n", { mode: 0o755 }); - fs.chmodSync(skillBinPath, 0o755); - process.env.OPENCLAW_HOME = tempHome; - saveExecApprovals({ - version: 1, - defaults: { - security: "allowlist", - ask: "on-miss", - askFallback: "deny", - autoAllowSkills: true, - }, - agents: {}, - }); const runCommand = vi.fn(async () => ({ success: true, stdout: "local-ok", @@ -282,39 +290,47 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { const sendInvokeResult = vi.fn(async () => {}); const sendNodeEvent = vi.fn(async () => {}); - try { - await handleSystemRunInvoke({ - client: {} as never, - params: { - command: ["./skill-bin", "--help"], - cwd: tempHome, - sessionKey: "agent:main:main", + await withTempApprovalsHome({ + approvals: { + version: 1, + defaults: { + security: "allowlist", + ask: "on-miss", + askFallback: "deny", + autoAllowSkills: true, }, - skillBins: { - current: async () => [{ name: "skill-bin", resolvedPath: skillBinPath }], - }, - execHostEnforced: false, - execHostFallbackAllowed: true, - resolveExecSecurity: () => "allowlist", - resolveExecAsk: () => "on-miss", - isCmdExeInvocation: () => false, - sanitizeEnv: () => undefined, - runCommand, - runViaMacAppExecHost: vi.fn(async () => null), - sendNodeEvent, - buildExecEventPayload: (payload) => payload, - sendInvokeResult, - sendExecFinishedEvent: vi.fn(async () => {}), - preferMacAppExecHost: false, - }); - } finally { - if (previousOpenClawHome === undefined) { - delete process.env.OPENCLAW_HOME; - } else { - process.env.OPENCLAW_HOME = previousOpenClawHome; - } - fs.rmSync(tempHome, { recursive: true, force: true }); - } + agents: {}, + }, + run: async ({ tempHome }) => { + const skillBinPath = path.join(tempHome, "skill-bin"); + fs.writeFileSync(skillBinPath, "#!/bin/sh\necho should-not-run\n", { mode: 0o755 }); + fs.chmodSync(skillBinPath, 0o755); + await handleSystemRunInvoke({ + client: {} as never, + params: { + command: ["./skill-bin", "--help"], + cwd: tempHome, + sessionKey: "agent:main:main", + }, + skillBins: { + current: async () => [{ name: "skill-bin", resolvedPath: skillBinPath }], + }, + execHostEnforced: false, + execHostFallbackAllowed: true, + resolveExecSecurity: () => "allowlist", + resolveExecAsk: () => "on-miss", + isCmdExeInvocation: () => false, + sanitizeEnv: () => undefined, + runCommand, + runViaMacAppExecHost: vi.fn(async () => null), + sendNodeEvent, + buildExecEventPayload: (payload) => payload, + sendInvokeResult, + sendExecFinishedEvent: vi.fn(async () => {}), + preferMacAppExecHost: false, + }); + }, + }); expect(runCommand).not.toHaveBeenCalled(); expect(sendNodeEvent).toHaveBeenCalledWith( @@ -353,82 +369,59 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { if (process.platform === "win32") { return; } - const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-depth-overflow-")); - const previousOpenClawHome = process.env.OPENCLAW_HOME; - const marker = path.join(tempHome, "pwned.txt"); - process.env.OPENCLAW_HOME = tempHome; - saveExecApprovals({ - version: 1, - defaults: { - security: "allowlist", - ask: "on-miss", - askFallback: "deny", - }, - agents: { - main: { - allowlist: [{ pattern: "/usr/bin/env" }], - }, - }, - }); const runCommand = vi.fn(async () => { - fs.writeFileSync(marker, "executed"); - return { - success: true, - stdout: "local-ok", - stderr: "", - timedOut: false, - truncated: false, - exitCode: 0, - error: null, - }; + throw new Error("runCommand should not be called for nested env depth overflow"); }); const sendInvokeResult = vi.fn(async () => {}); const sendNodeEvent = vi.fn(async () => {}); - try { - await handleSystemRunInvoke({ - client: {} as never, - params: { - command: [ - "/usr/bin/env", - "/usr/bin/env", - "/usr/bin/env", - "/usr/bin/env", - "/usr/bin/env", - "/bin/sh", - "-c", - `echo PWNED > ${marker}`, - ], - sessionKey: "agent:main:main", + await withTempApprovalsHome({ + approvals: { + version: 1, + defaults: { + security: "allowlist", + ask: "on-miss", + askFallback: "deny", }, - skillBins: { - current: async () => [], + agents: { + main: { + allowlist: [{ pattern: "/usr/bin/env" }], + }, }, - execHostEnforced: false, - execHostFallbackAllowed: true, - resolveExecSecurity: () => "allowlist", - resolveExecAsk: () => "on-miss", - isCmdExeInvocation: () => false, - sanitizeEnv: () => undefined, - runCommand, - runViaMacAppExecHost: vi.fn(async () => null), - sendNodeEvent, - buildExecEventPayload: (payload) => payload, - sendInvokeResult, - sendExecFinishedEvent: vi.fn(async () => {}), - preferMacAppExecHost: false, - }); - } finally { - if (previousOpenClawHome === undefined) { - delete process.env.OPENCLAW_HOME; - } else { - process.env.OPENCLAW_HOME = previousOpenClawHome; - } - fs.rmSync(tempHome, { recursive: true, force: true }); - } + }, + run: async ({ tempHome }) => { + const marker = path.join(tempHome, "pwned.txt"); + await handleSystemRunInvoke({ + client: {} as never, + params: { + command: buildNestedEnvShellCommand({ + depth: 5, + payload: `echo PWNED > ${marker}`, + }), + sessionKey: "agent:main:main", + }, + skillBins: { + current: async () => [], + }, + execHostEnforced: false, + execHostFallbackAllowed: true, + resolveExecSecurity: () => "allowlist", + resolveExecAsk: () => "on-miss", + isCmdExeInvocation: () => false, + sanitizeEnv: () => undefined, + runCommand, + runViaMacAppExecHost: vi.fn(async () => null), + sendNodeEvent, + buildExecEventPayload: (payload) => payload, + sendInvokeResult, + sendExecFinishedEvent: vi.fn(async () => {}), + preferMacAppExecHost: false, + }); + expect(fs.existsSync(marker)).toBe(false); + }, + }); expect(runCommand).not.toHaveBeenCalled(); - expect(fs.existsSync(marker)).toBe(false); expect(sendNodeEvent).toHaveBeenCalledWith( expect.anything(), "exec.denied", From 2a11c09a8d5ac98235b4db625ac6cc4c6a296a66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:43:44 +0000 Subject: [PATCH 322/408] fix: harden iMessage echo dedupe and reasoning suppression (#25897) --- CHANGELOG.md | 1 + ...i-embedded-subscribe.tools.extract.test.ts | 12 +++ src/agents/pi-embedded-subscribe.tools.ts | 7 +- src/auto-reply/reply/route-reply.test.ts | 12 +++ src/auto-reply/reply/route-reply.ts | 3 + src/imessage/monitor/deliver.test.ts | 26 ++++++ src/imessage/monitor/deliver.ts | 17 ++-- .../monitor/inbound-processing.test.ts | 60 +++++++++++++ src/imessage/monitor/inbound-processing.ts | 25 ++++-- .../monitor-provider.echo-cache.test.ts | 43 ++++++++++ src/imessage/monitor/monitor-provider.ts | 86 +++++++++++++------ src/infra/outbound/outbound.test.ts | 16 ++++ src/infra/outbound/payloads.ts | 3 + 13 files changed, 272 insertions(+), 39 deletions(-) create mode 100644 src/imessage/monitor/inbound-processing.test.ts create mode 100644 src/imessage/monitor/monitor-provider.echo-cache.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 29963ba844bf..ff2ebf053958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- iMessage/Reasoning safety: harden iMessage echo suppression with outbound `messageId` matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb. - Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 `dev=0` stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false `Local media path is not safe to read` drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng. - macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos. - macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl. diff --git a/src/agents/pi-embedded-subscribe.tools.extract.test.ts b/src/agents/pi-embedded-subscribe.tools.extract.test.ts index 4e002b4083a5..cd99ee6b6741 100644 --- a/src/agents/pi-embedded-subscribe.tools.extract.test.ts +++ b/src/agents/pi-embedded-subscribe.tools.extract.test.ts @@ -35,4 +35,16 @@ describe("extractMessagingToolSend", () => { expect(result?.provider).toBe("slack"); expect(result?.to).toBe("channel:C1"); }); + + it("accepts target alias when to is omitted", () => { + const result = extractMessagingToolSend("message", { + action: "send", + channel: "telegram", + target: "123", + }); + + expect(result?.tool).toBe("message"); + expect(result?.provider).toBe("telegram"); + expect(result?.to).toBe("telegram:123"); + }); }); diff --git a/src/agents/pi-embedded-subscribe.tools.ts b/src/agents/pi-embedded-subscribe.tools.ts index f162d0cbd761..745c1212709f 100644 --- a/src/agents/pi-embedded-subscribe.tools.ts +++ b/src/agents/pi-embedded-subscribe.tools.ts @@ -298,7 +298,12 @@ export function extractMessagingToolSend( if (action !== "send" && action !== "thread-reply") { return undefined; } - const toRaw = typeof args.to === "string" ? args.to : undefined; + const toRaw = + typeof args.to === "string" + ? args.to + : typeof args.target === "string" + ? args.target + : undefined; if (!toRaw) { return undefined; } diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 7712de8c7d6b..c6d726ebaf5a 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -144,6 +144,18 @@ describe("routeReply", () => { expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); }); + it("suppresses reasoning payloads", async () => { + mocks.sendMessageSlack.mockClear(); + const res = await routeReply({ + payload: { text: "Reasoning:\n_step_", isReasoning: true }, + channel: "slack", + to: "channel:C123", + cfg: {} as never, + }); + expect(res.ok).toBe(true); + expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); + }); + it("drops silent token payloads", async () => { mocks.sendMessageSlack.mockClear(); const res = await routeReply({ diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 3b6cc68b7e95..462d6a54d9b0 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -56,6 +56,9 @@ export type RouteReplyResult = { */ export async function routeReply(params: RouteReplyParams): Promise { const { payload, channel, to, accountId, threadId, cfg, abortSignal } = params; + if (payload.isReasoning) { + return { ok: true }; + } const normalizedChannel = normalizeMessageChannel(channel); const resolvedAgentId = params.sessionKey ? resolveSessionAgentId({ diff --git a/src/imessage/monitor/deliver.test.ts b/src/imessage/monitor/deliver.test.ts index 4c771b5fe57d..9db03d6ace54 100644 --- a/src/imessage/monitor/deliver.test.ts +++ b/src/imessage/monitor/deliver.test.ts @@ -123,4 +123,30 @@ describe("deliverReplies", () => { }), ); }); + + it("records outbound text and message ids in sent-message cache", async () => { + const remember = vi.fn(); + chunkTextWithModeMock.mockImplementation((text: string) => text.split("|")); + + await deliverReplies({ + replies: [{ text: "first|second" }], + target: "chat_id:30", + client, + accountId: "acct-3", + runtime, + maxBytes: 2048, + textLimit: 4000, + sentMessageCache: { remember }, + }); + + expect(remember).toHaveBeenCalledWith("acct-3:chat_id:30", { text: "first|second" }); + expect(remember).toHaveBeenCalledWith("acct-3:chat_id:30", { + text: "first", + messageId: "imsg-1", + }); + expect(remember).toHaveBeenCalledWith("acct-3:chat_id:30", { + text: "second", + messageId: "imsg-1", + }); + }); }); diff --git a/src/imessage/monitor/deliver.ts b/src/imessage/monitor/deliver.ts index 84bd8994c131..3e8f9391646d 100644 --- a/src/imessage/monitor/deliver.ts +++ b/src/imessage/monitor/deliver.ts @@ -8,7 +8,7 @@ import type { createIMessageRpcClient } from "../client.js"; import { sendMessageIMessage } from "../send.js"; type SentMessageCache = { - remember: (scope: string, text: string) => void; + remember: (scope: string, lookup: { text?: string; messageId?: string }) => void; }; export async function deliverReplies(params: { @@ -39,31 +39,32 @@ export async function deliverReplies(params: { continue; } if (mediaList.length === 0) { - sentMessageCache?.remember(scope, text); + sentMessageCache?.remember(scope, { text }); for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) { - await sendMessageIMessage(target, chunk, { + const sent = await sendMessageIMessage(target, chunk, { maxBytes, client, accountId, replyToId: payload.replyToId, }); - sentMessageCache?.remember(scope, chunk); + sentMessageCache?.remember(scope, { text: chunk, messageId: sent.messageId }); } } else { let first = true; for (const url of mediaList) { const caption = first ? text : ""; first = false; - await sendMessageIMessage(target, caption, { + const sent = await sendMessageIMessage(target, caption, { mediaUrl: url, maxBytes, client, accountId, replyToId: payload.replyToId, }); - if (caption) { - sentMessageCache?.remember(scope, caption); - } + sentMessageCache?.remember(scope, { + text: caption || undefined, + messageId: sent.messageId, + }); } } runtime.log?.(`imessage: delivered reply to ${target}`); diff --git a/src/imessage/monitor/inbound-processing.test.ts b/src/imessage/monitor/inbound-processing.test.ts new file mode 100644 index 000000000000..d63c41633184 --- /dev/null +++ b/src/imessage/monitor/inbound-processing.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { + describeIMessageEchoDropLog, + resolveIMessageInboundDecision, +} from "./inbound-processing.js"; + +describe("resolveIMessageInboundDecision echo detection", () => { + const cfg = {} as OpenClawConfig; + + it("drops inbound messages when outbound message id matches echo cache", () => { + const echoHas = vi.fn((_scope: string, lookup: { text?: string; messageId?: string }) => { + return lookup.messageId === "42"; + }); + + const decision = resolveIMessageInboundDecision({ + cfg, + accountId: "default", + message: { + id: 42, + sender: "+15555550123", + text: "Reasoning:\n_step_", + is_from_me: false, + is_group: false, + }, + opts: undefined, + messageText: "Reasoning:\n_step_", + bodyText: "Reasoning:\n_step_", + allowFrom: [], + groupAllowFrom: [], + groupPolicy: "open", + dmPolicy: "open", + storeAllowFrom: [], + historyLimit: 0, + groupHistories: new Map(), + echoCache: { has: echoHas }, + logVerbose: undefined, + }); + + expect(decision).toEqual({ kind: "drop", reason: "echo" }); + expect(echoHas).toHaveBeenCalledWith( + "default:imessage:+15555550123", + expect.objectContaining({ + text: "Reasoning:\n_step_", + messageId: "42", + }), + ); + }); +}); + +describe("describeIMessageEchoDropLog", () => { + it("includes message id when available", () => { + expect( + describeIMessageEchoDropLog({ + messageText: "Reasoning:\n_step_", + messageId: "abc-123", + }), + ).toContain("id=abc-123"); + }); +}); diff --git a/src/imessage/monitor/inbound-processing.ts b/src/imessage/monitor/inbound-processing.ts index 5f4757bf5423..cf51e958b31f 100644 --- a/src/imessage/monitor/inbound-processing.ts +++ b/src/imessage/monitor/inbound-processing.ts @@ -95,7 +95,7 @@ export function resolveIMessageInboundDecision(params: { storeAllowFrom: string[]; historyLimit: number; groupHistories: Map; - echoCache?: { has: (scope: string, text: string) => boolean }; + echoCache?: { has: (scope: string, lookup: { text?: string; messageId?: string }) => boolean }; logVerbose?: (msg: string) => void; }): IMessageInboundDecision { const senderRaw = params.message.sender ?? ""; @@ -224,15 +224,23 @@ export function resolveIMessageInboundDecision(params: { // Echo detection: check if the received message matches a recently sent message (within 5 seconds). // Scope by conversation so same text in different chats is not conflated. - if (params.echoCache && messageText) { + const inboundMessageId = params.message.id != null ? String(params.message.id) : undefined; + if (params.echoCache && (messageText || inboundMessageId)) { const echoScope = buildIMessageEchoScope({ accountId: params.accountId, isGroup, chatId, sender, }); - if (params.echoCache.has(echoScope, messageText)) { - params.logVerbose?.(describeIMessageEchoDropLog({ messageText })); + if ( + params.echoCache.has(echoScope, { + text: messageText || undefined, + messageId: inboundMessageId, + }) + ) { + params.logVerbose?.( + describeIMessageEchoDropLog({ messageText, messageId: inboundMessageId }), + ); return { kind: "drop", reason: "echo" }; } } @@ -479,6 +487,11 @@ export function buildIMessageEchoScope(params: { return `${params.accountId}:${params.isGroup ? formatIMessageChatTarget(params.chatId) : `imessage:${params.sender}`}`; } -export function describeIMessageEchoDropLog(params: { messageText: string }): string { - return `imessage: skipping echo message (matches recently sent text within 5s): "${truncateUtf16Safe(params.messageText, 50)}"`; +export function describeIMessageEchoDropLog(params: { + messageText: string; + messageId?: string; +}): string { + const preview = truncateUtf16Safe(params.messageText, 50); + const messageIdPart = params.messageId ? ` id=${params.messageId}` : ""; + return `imessage: skipping echo message${messageIdPart}: "${preview}"`; } diff --git a/src/imessage/monitor/monitor-provider.echo-cache.test.ts b/src/imessage/monitor/monitor-provider.echo-cache.test.ts new file mode 100644 index 000000000000..766b2bf00fbe --- /dev/null +++ b/src/imessage/monitor/monitor-provider.echo-cache.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { __testing } from "./monitor-provider.js"; + +describe("iMessage sent-message echo cache", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("matches recent text within the same scope", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-25T00:00:00Z")); + const cache = __testing.createSentMessageCache(); + + cache.remember("acct:imessage:+1555", { text: " Reasoning:\r\n_step_ " }); + + expect(cache.has("acct:imessage:+1555", { text: "Reasoning:\n_step_" })).toBe(true); + expect(cache.has("acct:imessage:+1666", { text: "Reasoning:\n_step_" })).toBe(false); + }); + + it("matches by outbound message id and ignores placeholder ids", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-25T00:00:00Z")); + const cache = __testing.createSentMessageCache(); + + cache.remember("acct:imessage:+1555", { messageId: "abc-123" }); + cache.remember("acct:imessage:+1555", { messageId: "ok" }); + + expect(cache.has("acct:imessage:+1555", { messageId: "abc-123" })).toBe(true); + expect(cache.has("acct:imessage:+1555", { messageId: "ok" })).toBe(false); + }); + + it("keeps message-id lookups longer than text fallback", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-25T00:00:00Z")); + const cache = __testing.createSentMessageCache(); + + cache.remember("acct:imessage:+1555", { text: "hello", messageId: "m-1" }); + vi.advanceTimersByTime(6000); + + expect(cache.has("acct:imessage:+1555", { text: "hello" })).toBe(false); + expect(cache.has("acct:imessage:+1555", { messageId: "m-1" })).toBe(true); + }); +}); diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 703935954b15..c585df1f8b37 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -83,43 +83,80 @@ async function detectRemoteHostFromCliPath(cliPath: string): Promise(); - private readonly ttlMs = 5000; // 5 seconds + private textCache = new Map(); + private messageIdCache = new Map(); - remember(scope: string, text: string): void { - if (!text?.trim()) { - return; + remember(scope: string, lookup: SentMessageLookup): void { + const textKey = normalizeEchoTextKey(lookup.text); + if (textKey) { + this.textCache.set(`${scope}:${textKey}`, Date.now()); + } + const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId); + if (messageIdKey) { + this.messageIdCache.set(`${scope}:${messageIdKey}`, Date.now()); } - const key = `${scope}:${text.trim()}`; - this.cache.set(key, Date.now()); this.cleanup(); } - has(scope: string, text: string): boolean { - if (!text?.trim()) { - return false; - } - const key = `${scope}:${text.trim()}`; - const timestamp = this.cache.get(key); - if (!timestamp) { - return false; + has(scope: string, lookup: SentMessageLookup): boolean { + this.cleanup(); + const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId); + if (messageIdKey) { + const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`); + if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) { + return true; + } } - const age = Date.now() - timestamp; - if (age > this.ttlMs) { - this.cache.delete(key); - return false; + const textKey = normalizeEchoTextKey(lookup.text); + if (textKey) { + const textTimestamp = this.textCache.get(`${scope}:${textKey}`); + if (textTimestamp && Date.now() - textTimestamp <= SENT_MESSAGE_TEXT_TTL_MS) { + return true; + } } - return true; + return false; } private cleanup(): void { const now = Date.now(); - for (const [text, timestamp] of this.cache.entries()) { - if (now - timestamp > this.ttlMs) { - this.cache.delete(text); + for (const [key, timestamp] of this.textCache.entries()) { + if (now - timestamp > SENT_MESSAGE_TEXT_TTL_MS) { + this.textCache.delete(key); + } + } + for (const [key, timestamp] of this.messageIdCache.entries()) { + if (now - timestamp > SENT_MESSAGE_ID_TTL_MS) { + this.messageIdCache.delete(key); } } } @@ -527,4 +564,5 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P export const __testing = { resolveIMessageRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, + createSentMessageCache: () => new SentMessageCache(), }; diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index 8b57bb7a34fb..64960ec143c3 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -908,6 +908,14 @@ describe("normalizeOutboundPayloadsForJson", () => { expect(normalizeOutboundPayloadsForJson(input)).toEqual(testCase.expected); } }); + + it("suppresses reasoning payloads", () => { + const normalized = normalizeOutboundPayloadsForJson([ + { text: "Reasoning:\n_step_", isReasoning: true }, + { text: "final answer" }, + ]); + expect(normalized).toEqual([{ text: "final answer", mediaUrl: null, mediaUrls: undefined }]); + }); }); describe("normalizeOutboundPayloads", () => { @@ -916,6 +924,14 @@ describe("normalizeOutboundPayloads", () => { const normalized = normalizeOutboundPayloads([{ channelData }]); expect(normalized).toEqual([{ text: "", mediaUrls: [], channelData }]); }); + + it("suppresses reasoning payloads", () => { + const normalized = normalizeOutboundPayloads([ + { text: "Reasoning:\n_step_", isReasoning: true }, + { text: "final answer" }, + ]); + expect(normalized).toEqual([{ text: "final answer", mediaUrls: [] }]); + }); }); describe("formatOutboundPayloadLog", () => { diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index f61261939c11..98d67ce90bb5 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -41,6 +41,9 @@ export function normalizeReplyPayloadsForDelivery( payloads: readonly ReplyPayload[], ): ReplyPayload[] { return payloads.flatMap((payload) => { + if (payload.isReasoning) { + return []; + } const parsed = parseReplyDirectives(payload.text ?? ""); const explicitMediaUrls = payload.mediaUrls ?? parsed.mediaUrls; const explicitMediaUrl = payload.mediaUrl ?? parsed.mediaUrl; From 196a7dbd24fcf94cbd91f2fac2fc7592f296a3d3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:46:42 +0000 Subject: [PATCH 323/408] test(media): add win32 dev=0 local media regression --- src/web/media.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/web/media.test.ts b/src/web/media.test.ts index 4bfcc7fddb19..d91ed4b7d66f 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -50,6 +50,10 @@ async function createLargeTestJpeg(): Promise<{ buffer: Buffer; file: string }> return { buffer: largeJpegBuffer, file: largeJpegFile }; } +function cloneStatWithDev(stat: T, dev: number | bigint): T { + return Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, { dev }) as T; +} + beforeAll(async () => { fixtureRoot = await fs.mkdtemp( path.join(resolvePreferredOpenClawTmpDir(), "openclaw-media-test-"), @@ -357,6 +361,30 @@ describe("local media root guard", () => { expect(result.kind).toBe("image"); }); + it("accepts win32 dev=0 stat mismatch for local file loads", async () => { + const actualLstat = await fs.lstat(tinyPngFile); + const actualStat = await fs.stat(tinyPngFile); + const zeroDev = typeof actualLstat.dev === "bigint" ? 0n : 0; + + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const lstatSpy = vi + .spyOn(fs, "lstat") + .mockResolvedValue(cloneStatWithDev(actualLstat, zeroDev)); + const statSpy = vi.spyOn(fs, "stat").mockResolvedValue(cloneStatWithDev(actualStat, zeroDev)); + + try { + const result = await loadWebMedia(tinyPngFile, 1024 * 1024, { + localRoots: [resolvePreferredOpenClawTmpDir()], + }); + expect(result.kind).toBe("image"); + expect(result.buffer.length).toBeGreaterThan(0); + } finally { + statSpy.mockRestore(); + lstatSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + it("requires readFile override for localRoots bypass", async () => { await expect( loadWebMedia(tinyPngFile, { From 5c6b2cbc8eb50d017a0faefcd919b749c3fbede6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:53:39 +0000 Subject: [PATCH 324/408] refactor: extract iMessage echo cache and unify suppression guards --- src/agents/pi-embedded-subscribe.tools.ts | 15 ++-- src/auto-reply/reply/dispatch-from-config.ts | 5 +- src/auto-reply/reply/reply-payloads.ts | 4 + src/auto-reply/reply/route-reply.ts | 3 +- src/imessage/monitor/deliver.ts | 7 +- src/imessage/monitor/echo-cache.ts | 85 ++++++++++++++++++ .../monitor-provider.echo-cache.test.ts | 8 +- src/imessage/monitor/monitor-provider.ts | 86 +------------------ src/infra/outbound/payloads.ts | 7 +- 9 files changed, 116 insertions(+), 104 deletions(-) create mode 100644 src/imessage/monitor/echo-cache.ts diff --git a/src/agents/pi-embedded-subscribe.tools.ts b/src/agents/pi-embedded-subscribe.tools.ts index 745c1212709f..08a5e5f80c44 100644 --- a/src/agents/pi-embedded-subscribe.tools.ts +++ b/src/agents/pi-embedded-subscribe.tools.ts @@ -286,6 +286,14 @@ export function extractToolErrorMessage(result: unknown): string | undefined { return normalizeToolErrorText(text); } +function resolveMessageToolTarget(args: Record): string | undefined { + const toRaw = typeof args.to === "string" ? args.to : undefined; + if (toRaw) { + return toRaw; + } + return typeof args.target === "string" ? args.target : undefined; +} + export function extractMessagingToolSend( toolName: string, args: Record, @@ -298,12 +306,7 @@ export function extractMessagingToolSend( if (action !== "send" && action !== "thread-reply") { return undefined; } - const toRaw = - typeof args.to === "string" - ? args.to - : typeof args.target === "string" - ? args.target - : undefined; + const toRaw = resolveMessageToolTarget(args); if (!toRaw) { return undefined; } diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 96989ff98ea9..881b1afe6fed 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -17,6 +17,7 @@ import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js"; import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js"; import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js"; +import { shouldSuppressReasoningPayload } from "./reply-payloads.js"; import { isRoutableChannel, routeReply } from "./route-reply.js"; const AUDIO_PLACEHOLDER_RE = /^(\s*\([^)]*\))?$/i; @@ -366,7 +367,7 @@ export async function dispatchReplyFromConfig(params: { // Suppress reasoning payloads — channels using this generic dispatch // path (WhatsApp, web, etc.) do not have a dedicated reasoning lane. // Telegram has its own dispatch path that handles reasoning splitting. - if (payload.isReasoning) { + if (shouldSuppressReasoningPayload(payload)) { return; } // Accumulate block text for TTS generation after streaming @@ -404,7 +405,7 @@ export async function dispatchReplyFromConfig(params: { for (const reply of replies) { // Suppress reasoning payloads from channel delivery — channels using this // generic dispatch path do not have a dedicated reasoning lane. - if (reply.isReasoning) { + if (shouldSuppressReasoningPayload(reply)) { continue; } const ttsReply = await maybeApplyTtsToPayload({ diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index 41906f1227f6..a408e942a2d3 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -68,6 +68,10 @@ export function isRenderablePayload(payload: ReplyPayload): boolean { ); } +export function shouldSuppressReasoningPayload(payload: ReplyPayload): boolean { + return payload.isReasoning === true; +} + export function applyReplyThreading(params: { payloads: ReplyPayload[]; replyToMode: ReplyToMode; diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 462d6a54d9b0..081fd58a04ab 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -15,6 +15,7 @@ import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/m import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; import { normalizeReplyPayload } from "./normalize-reply.js"; +import { shouldSuppressReasoningPayload } from "./reply-payloads.js"; export type RouteReplyParams = { /** The reply payload to send. */ @@ -56,7 +57,7 @@ export type RouteReplyResult = { */ export async function routeReply(params: RouteReplyParams): Promise { const { payload, channel, to, accountId, threadId, cfg, abortSignal } = params; - if (payload.isReasoning) { + if (shouldSuppressReasoningPayload(payload)) { return { ok: true }; } const normalizedChannel = normalizeMessageChannel(channel); diff --git a/src/imessage/monitor/deliver.ts b/src/imessage/monitor/deliver.ts index 3e8f9391646d..71825be8d0ba 100644 --- a/src/imessage/monitor/deliver.ts +++ b/src/imessage/monitor/deliver.ts @@ -6,10 +6,7 @@ import { convertMarkdownTables } from "../../markdown/tables.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { createIMessageRpcClient } from "../client.js"; import { sendMessageIMessage } from "../send.js"; - -type SentMessageCache = { - remember: (scope: string, lookup: { text?: string; messageId?: string }) => void; -}; +import type { SentMessageCache } from "./echo-cache.js"; export async function deliverReplies(params: { replies: ReplyPayload[]; @@ -19,7 +16,7 @@ export async function deliverReplies(params: { runtime: RuntimeEnv; maxBytes: number; textLimit: number; - sentMessageCache?: SentMessageCache; + sentMessageCache?: Pick; }) { const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } = params; diff --git a/src/imessage/monitor/echo-cache.ts b/src/imessage/monitor/echo-cache.ts new file mode 100644 index 000000000000..c68ff04b9702 --- /dev/null +++ b/src/imessage/monitor/echo-cache.ts @@ -0,0 +1,85 @@ +export type SentMessageLookup = { + text?: string; + messageId?: string; +}; + +export type SentMessageCache = { + remember: (scope: string, lookup: SentMessageLookup) => void; + has: (scope: string, lookup: SentMessageLookup) => boolean; +}; + +const SENT_MESSAGE_TEXT_TTL_MS = 5000; +const SENT_MESSAGE_ID_TTL_MS = 60_000; + +function normalizeEchoTextKey(text: string | undefined): string | null { + if (!text) { + return null; + } + const normalized = text.replace(/\r\n?/g, "\n").trim(); + return normalized ? normalized : null; +} + +function normalizeEchoMessageIdKey(messageId: string | undefined): string | null { + if (!messageId) { + return null; + } + const normalized = messageId.trim(); + if (!normalized || normalized === "ok" || normalized === "unknown") { + return null; + } + return normalized; +} + +class DefaultSentMessageCache implements SentMessageCache { + private textCache = new Map(); + private messageIdCache = new Map(); + + remember(scope: string, lookup: SentMessageLookup): void { + const textKey = normalizeEchoTextKey(lookup.text); + if (textKey) { + this.textCache.set(`${scope}:${textKey}`, Date.now()); + } + const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId); + if (messageIdKey) { + this.messageIdCache.set(`${scope}:${messageIdKey}`, Date.now()); + } + this.cleanup(); + } + + has(scope: string, lookup: SentMessageLookup): boolean { + this.cleanup(); + const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId); + if (messageIdKey) { + const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`); + if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) { + return true; + } + } + const textKey = normalizeEchoTextKey(lookup.text); + if (textKey) { + const textTimestamp = this.textCache.get(`${scope}:${textKey}`); + if (textTimestamp && Date.now() - textTimestamp <= SENT_MESSAGE_TEXT_TTL_MS) { + return true; + } + } + return false; + } + + private cleanup(): void { + const now = Date.now(); + for (const [key, timestamp] of this.textCache.entries()) { + if (now - timestamp > SENT_MESSAGE_TEXT_TTL_MS) { + this.textCache.delete(key); + } + } + for (const [key, timestamp] of this.messageIdCache.entries()) { + if (now - timestamp > SENT_MESSAGE_ID_TTL_MS) { + this.messageIdCache.delete(key); + } + } + } +} + +export function createSentMessageCache(): SentMessageCache { + return new DefaultSentMessageCache(); +} diff --git a/src/imessage/monitor/monitor-provider.echo-cache.test.ts b/src/imessage/monitor/monitor-provider.echo-cache.test.ts index 766b2bf00fbe..e67667c02285 100644 --- a/src/imessage/monitor/monitor-provider.echo-cache.test.ts +++ b/src/imessage/monitor/monitor-provider.echo-cache.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { __testing } from "./monitor-provider.js"; +import { createSentMessageCache } from "./echo-cache.js"; describe("iMessage sent-message echo cache", () => { afterEach(() => { @@ -9,7 +9,7 @@ describe("iMessage sent-message echo cache", () => { it("matches recent text within the same scope", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-25T00:00:00Z")); - const cache = __testing.createSentMessageCache(); + const cache = createSentMessageCache(); cache.remember("acct:imessage:+1555", { text: " Reasoning:\r\n_step_ " }); @@ -20,7 +20,7 @@ describe("iMessage sent-message echo cache", () => { it("matches by outbound message id and ignores placeholder ids", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-25T00:00:00Z")); - const cache = __testing.createSentMessageCache(); + const cache = createSentMessageCache(); cache.remember("acct:imessage:+1555", { messageId: "abc-123" }); cache.remember("acct:imessage:+1555", { messageId: "ok" }); @@ -32,7 +32,7 @@ describe("iMessage sent-message echo cache", () => { it("keeps message-id lookups longer than text fallback", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-25T00:00:00Z")); - const cache = __testing.createSentMessageCache(); + const cache = createSentMessageCache(); cache.remember("acct:imessage:+1555", { text: "hello", messageId: "m-1" }); vi.advanceTimersByTime(6000); diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index c585df1f8b37..3bfdc691163b 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -44,6 +44,7 @@ import { probeIMessage } from "../probe.js"; import { sendMessageIMessage } from "../send.js"; import { attachIMessageMonitorAbortHandler } from "./abort-handler.js"; import { deliverReplies } from "./deliver.js"; +import { createSentMessageCache } from "./echo-cache.js"; import { buildIMessageInboundContext, resolveIMessageInboundDecision, @@ -80,88 +81,6 @@ async function detectRemoteHostFromCliPath(cliPath: string): Promise(); - private messageIdCache = new Map(); - - remember(scope: string, lookup: SentMessageLookup): void { - const textKey = normalizeEchoTextKey(lookup.text); - if (textKey) { - this.textCache.set(`${scope}:${textKey}`, Date.now()); - } - const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId); - if (messageIdKey) { - this.messageIdCache.set(`${scope}:${messageIdKey}`, Date.now()); - } - this.cleanup(); - } - - has(scope: string, lookup: SentMessageLookup): boolean { - this.cleanup(); - const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId); - if (messageIdKey) { - const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`); - if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) { - return true; - } - } - const textKey = normalizeEchoTextKey(lookup.text); - if (textKey) { - const textTimestamp = this.textCache.get(`${scope}:${textKey}`); - if (textTimestamp && Date.now() - textTimestamp <= SENT_MESSAGE_TEXT_TTL_MS) { - return true; - } - } - return false; - } - - private cleanup(): void { - const now = Date.now(); - for (const [key, timestamp] of this.textCache.entries()) { - if (now - timestamp > SENT_MESSAGE_TEXT_TTL_MS) { - this.textCache.delete(key); - } - } - for (const [key, timestamp] of this.messageIdCache.entries()) { - if (now - timestamp > SENT_MESSAGE_ID_TTL_MS) { - this.messageIdCache.delete(key); - } - } - } -} - export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise { const runtime = resolveRuntime(opts); const cfg = opts.config ?? loadConfig(); @@ -177,7 +96,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P DEFAULT_GROUP_HISTORY_LIMIT, ); const groupHistories = new Map(); - const sentMessageCache = new SentMessageCache(); + const sentMessageCache = createSentMessageCache(); const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId); const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom); const groupAllowFrom = normalizeAllowList( @@ -564,5 +483,4 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P export const __testing = { resolveIMessageRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, - createSentMessageCache: () => new SentMessageCache(), }; diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index 98d67ce90bb5..c5c99d0038bc 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -1,5 +1,8 @@ import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; -import { isRenderablePayload } from "../../auto-reply/reply/reply-payloads.js"; +import { + isRenderablePayload, + shouldSuppressReasoningPayload, +} from "../../auto-reply/reply/reply-payloads.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; export type NormalizedOutboundPayload = { @@ -41,7 +44,7 @@ export function normalizeReplyPayloadsForDelivery( payloads: readonly ReplyPayload[], ): ReplyPayload[] { return payloads.flatMap((payload) => { - if (payload.isReasoning) { + if (shouldSuppressReasoningPayload(payload)) { return []; } const parsed = parseReplyDirectives(payload.text ?? ""); From 2157c490afcf3c787517a3a37d1a8cb6cec4e5c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:58:12 +0000 Subject: [PATCH 325/408] test: normalize tmp media path assertion for windows --- src/infra/outbound/message-action-runner.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index ed1fbf47eb3b..6fdec33ab492 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -706,7 +706,8 @@ describe("runMessageAction sandboxed media validation", () => { if (result.kind !== "send") { throw new Error("expected send result"); } - expect(result.sendResult?.mediaUrl).toBe(tmpFile); + // runMessageAction normalizes media paths through platform resolution. + expect(result.sendResult?.mediaUrl).toBe(path.resolve(tmpFile)); const hostTmpOutsideOpenClaw = path.join(os.tmpdir(), "outside-openclaw", "test-media.png"); await expect( runMessageAction({ From 8470dff6197119b0b982eb878f417e62b22c5c37 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:10:36 +0000 Subject: [PATCH 326/408] chore(deps): update dependencies except carbon --- CHANGELOG.md | 1 + extensions/googlechat/package.json | 2 +- extensions/memory-lancedb/package.json | 2 +- package.json | 20 +- pnpm-lock.yaml | 1666 ++++++++++++++++++++---- 5 files changed, 1395 insertions(+), 296 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff2ebf053958..53a58d8ae4ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping `@buape/carbon` pinned. - Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms), and treat exact `do not do that` as a stop trigger while preserving strict standalone matching. (#25103) Thanks @steipete and @vincentkoc. - Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes). diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index ed2ab0c7fa10..4828b5cdfb4a 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Google Chat channel plugin", "type": "module", "dependencies": { - "google-auth-library": "^10.5.0" + "google-auth-library": "^10.6.1" }, "peerDependencies": { "openclaw": ">=2026.1.26" diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index 3925a7a534b7..0548f4bacec6 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -7,7 +7,7 @@ "dependencies": { "@lancedb/lancedb": "^0.26.2", "@sinclair/typebox": "0.34.48", - "openai": "^6.22.0" + "openai": "^6.25.0" }, "openclaw": { "extensions": [ diff --git a/package.json b/package.json index c2f69c7286fb..b04f08ea3c93 100644 --- a/package.json +++ b/package.json @@ -141,7 +141,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "0.14.1", - "@aws-sdk/client-bedrock": "^3.995.0", + "@aws-sdk/client-bedrock": "^3.997.0", "@buape/carbon": "0.0.0-beta-20260216184201", "@clack/prompts": "^1.0.1", "@discordjs/voice": "^0.19.0", @@ -151,10 +151,10 @@ "@larksuiteoapi/node-sdk": "^1.59.0", "@line/bot-sdk": "^10.6.0", "@lydell/node-pty": "1.2.0-beta.3", - "@mariozechner/pi-agent-core": "0.54.1", - "@mariozechner/pi-ai": "0.54.1", - "@mariozechner/pi-coding-agent": "0.54.1", - "@mariozechner/pi-tui": "0.54.1", + "@mariozechner/pi-agent-core": "0.55.0", + "@mariozechner/pi-ai": "0.55.0", + "@mariozechner/pi-coding-agent": "0.55.0", + "@mariozechner/pi-tui": "0.55.0", "@mozilla/readability": "^0.6.0", "@sinclair/typebox": "0.34.48", "@slack/bolt": "^4.6.0", @@ -181,7 +181,7 @@ "long": "^5.3.2", "markdown-it": "^14.1.1", "node-edge-tts": "^1.2.10", - "opusscript": "^0.0.8", + "opusscript": "^0.1.1", "osc-progress": "^0.3.0", "pdfjs-dist": "^5.4.624", "playwright-core": "1.58.2", @@ -204,12 +204,12 @@ "@types/node": "^25.3.0", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", - "@typescript/native-preview": "7.0.0-dev.20260222.1", + "@typescript/native-preview": "7.0.0-dev.20260224.1", "@vitest/coverage-v8": "^4.0.18", "lit": "^3.3.2", - "oxfmt": "0.34.0", - "oxlint": "^1.49.0", - "oxlint-tsgolint": "^0.14.2", + "oxfmt": "0.35.0", + "oxlint": "^1.50.0", + "oxlint-tsgolint": "^0.15.0", "signal-utils": "0.21.1", "tsdown": "^0.20.3", "tsx": "^4.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46a7f41fcb4c..365b0ee17072 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,17 +24,17 @@ importers: specifier: 0.14.1 version: 0.14.1(zod@4.3.6) '@aws-sdk/client-bedrock': - specifier: ^3.995.0 - version: 3.995.0 + specifier: ^3.997.0 + version: 3.997.0 '@buape/carbon': specifier: 0.0.0-beta-20260216184201 - version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.0.8) + version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.1.1) '@clack/prompts': specifier: ^1.0.1 version: 1.0.1 '@discordjs/voice': specifier: ^0.19.0 - version: 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8) + version: 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) '@grammyjs/runner': specifier: ^2.0.3 version: 2.0.3(grammy@1.40.0) @@ -54,17 +54,17 @@ importers: specifier: 1.2.0-beta.3 version: 1.2.0-beta.3 '@mariozechner/pi-agent-core': - specifier: 0.54.1 - version: 0.54.1(ws@8.19.0)(zod@4.3.6) + specifier: 0.55.0 + version: 0.55.0(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': - specifier: 0.54.1 - version: 0.54.1(ws@8.19.0)(zod@4.3.6) + specifier: 0.55.0 + version: 0.55.0(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-coding-agent': - specifier: 0.54.1 - version: 0.54.1(ws@8.19.0)(zod@4.3.6) + specifier: 0.55.0 + version: 0.55.0(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-tui': - specifier: 0.54.1 - version: 0.54.1 + specifier: 0.55.0 + version: 0.55.0 '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -150,8 +150,8 @@ importers: specifier: 3.15.1 version: 3.15.1(typescript@5.9.3) opusscript: - specifier: ^0.0.8 - version: 0.0.8 + specifier: ^0.1.1 + version: 0.1.1 osc-progress: specifier: ^0.3.0 version: 0.3.0 @@ -214,8 +214,8 @@ importers: specifier: ^8.18.1 version: 8.18.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260222.1 - version: 7.0.0-dev.20260222.1 + specifier: 7.0.0-dev.20260224.1 + version: 7.0.0-dev.20260224.1 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) @@ -223,20 +223,20 @@ importers: specifier: ^3.3.2 version: 3.3.2 oxfmt: - specifier: 0.34.0 - version: 0.34.0 + specifier: 0.35.0 + version: 0.35.0 oxlint: - specifier: ^1.49.0 - version: 1.49.0(oxlint-tsgolint@0.14.2) + specifier: ^1.50.0 + version: 1.50.0(oxlint-tsgolint@0.15.0) oxlint-tsgolint: - specifier: ^0.14.2 - version: 0.14.2 + specifier: ^0.15.0 + version: 0.15.0 signal-utils: specifier: 0.21.1 version: 0.21.1(signal-polyfill@0.2.2) tsdown: specifier: ^0.20.3 - version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260222.1)(typescript@5.9.3) + version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260224.1)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -310,8 +310,8 @@ importers: extensions/googlechat: dependencies: google-auth-library: - specifier: ^10.5.0 - version: 10.5.0 + specifier: ^10.6.1 + version: 10.6.1 openclaw: specifier: '>=2026.1.26' version: 2026.2.23(@napi-rs/canvas@0.1.94)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) @@ -361,8 +361,8 @@ importers: specifier: 0.34.48 version: 0.34.48 openai: - specifier: ^6.22.0 - version: 6.22.0(ws@8.19.0)(zod@4.3.6) + specifier: ^6.25.0 + version: 6.25.0(ws@8.19.0)(zod@4.3.6) extensions/minimax-portal-auth: {} @@ -536,10 +536,18 @@ packages: resolution: {integrity: sha512-nI7tT11L9s34AKr95GHmxs6k2+3ie+rEOew2cXOwsMC9k/5aifrZwh0JjAkBop4FqbmS8n0ZjCKDjBZFY/0YxQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock-runtime@3.997.0': + resolution: {integrity: sha512-yEgCc/HvI7dLeXQLCuc4cnbzwE/NbNpKX8NmSSWTy3jnjiMZwrNKdHMBgPoNvaEb0klHhnTyO+JCHVVCPI/eYw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock@3.995.0': resolution: {integrity: sha512-ONw5c7pOeHe78kC+jK2j73hP727Kqp7cc9lZqkfshlBD8MWxXmZM9GihIQLrNBCSUKRhc19NH7DUM6B7uN0mMQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock@3.997.0': + resolution: {integrity: sha512-PMRqxSzfkQHbU7ADVlT4jYLB7beFQWLXN9CGI9D9P8eqCIaDVv3YxTfwcT3FcBVucqktdTBTEowhvKn0whr/rA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-sso@3.993.0': resolution: {integrity: sha512-VLUN+wIeNX24fg12SCbzTUBnBENlL014yMKZvRhPkcn4wHR6LKgNrjsG3fZ03Xs0XoKaGtNFi1VVrq666sGBoQ==} engines: {node: '>=20.0.0'} @@ -548,6 +556,14 @@ packages: resolution: {integrity: sha512-wdQ8vrvHkKIV7yNUKXyjPWKCdYEUrZTHJ8Ojd5uJxXp9vqPCkUR1dpi1NtOLcrDgueJH7MUH5lQZxshjFPSbDA==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.973.13': + resolution: {integrity: sha512-eCFiLyBhJR7c/i8hZOETdzj2wsLFzi2L/w9/jajOgwmGqO8xrUExqkTZqdjROkwU62owqeqSuw4sIzlCv1E/ww==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.11': + resolution: {integrity: sha512-hbyoFuVm3qOAGfIPS9t7jCs8GFLFoaOs8ZmYp/chqciuHDyEGv+J365ip7YSvXSrxxUbeW9NyB1hTLt40NBMRg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.9': resolution: {integrity: sha512-ZptrOwQynfupubvcngLkbdIq/aXvl/czdpEG8XJ8mN8Nb19BR0jaK0bR+tfuMU36Ez9q4xv7GGkHFqEEP2hUUQ==} engines: {node: '>=20.0.0'} @@ -556,10 +572,22 @@ packages: resolution: {integrity: sha512-hECWoOoH386bGr89NQc9vA/abkGf5TJrMREt+lhNcnSNmoBS04fK7vc3LrJBSQAUGGVj0Tz3f4dHB3w5veovig==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.13': + resolution: {integrity: sha512-a864QxQWFkdCZ5wQF0QZNKTbqAc/DFQNeARp4gOyZZdql5RHjj4CppUSfwAzS9cpw2IPY3eeJjWqLZ1QiDB/6w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.11': + resolution: {integrity: sha512-kvPFn626ABLzxmjFMoqMRtmFKMeiUdWPhwxhmuPu233tqHnNuXzHv0MtrZlkzHd+rwlh9j0zCbQo89B54wIazQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.9': resolution: {integrity: sha512-zr1csEu9n4eDiHMTYJabX1mDGuGLgjgUnNckIivvk43DocJC9/f6DefFrnUPZXE+GHtbW50YuXb+JIxKykU74A==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.11': + resolution: {integrity: sha512-stdy09EpBTmsxGiXe1vB5qtXNww9wact36/uWLlSV0/vWbCOUAY2JjhPXoDVLk8n+E6r0M5HeZseLk+iTtifxg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.9': resolution: {integrity: sha512-m4RIpVgZChv0vWS/HKChg1xLgZPpx8Z+ly9Fv7FwA8SOfuC6I3htcSaBz2Ch4bneRIiBUhwP4ziUo0UZgtJStQ==} engines: {node: '>=20.0.0'} @@ -568,14 +596,30 @@ packages: resolution: {integrity: sha512-70nCESlvnzjo4LjJ8By8MYIiBogkYPSXl3WmMZfH9RZcB/Nt9qVWbFpYj6Fk1vLa4Vk8qagFVeXgxdieMxG1QA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.12': + resolution: {integrity: sha512-gMWGnHbNSKWRj+PAiuSg0EDpEwpyIgk0v9U6EuZ1C/5/BUv25Way+E+UFB7r+YYkscuBJMJ+ai8E2K0Q8dx50g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.11': + resolution: {integrity: sha512-B049fvbv41vf0Fs5bCtbzHpruBDp61sPiFDxUmkAJ/zvgSAturpj2rqzV1rj2clg4mb44Uxp9rgpcODexNFlFA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.9': resolution: {integrity: sha512-gOWl0Fe2gETj5Bk151+LYKpeGi2lBDLNu+NMNpHRlIrKHdBmVun8/AalwMK8ci4uRfG5a3/+zvZBMpuen1SZ0A==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.11': + resolution: {integrity: sha512-vX9z8skN8vPtamVWmSCm4KQohub+1uMuRzIo4urZ2ZUMBAl1bqHatVD/roCb3qRfAyIGvZXCA/AWS03BQRMyCQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.9': resolution: {integrity: sha512-ey7S686foGTArvFhi3ifQXmgptKYvLSGE2250BAQceMSXZddz7sUSNERGJT2S7u5KIe/kgugxrt01hntXVln6w==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.11': + resolution: {integrity: sha512-VR2Ju/QBdOjnWNIYuxRml63eFDLGc6Zl8aDwLi1rzgWo3rLBgtaWhWVBAijhVXzyPdQIOqdL8hvll5ybqumjeQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.9': resolution: {integrity: sha512-8LnfS76nHXoEc9aRRiMMpxZxJeDG0yusdyo3NvPhCgESmBUgpMa4luhGbClW5NoX/qRcGxxM6Z/esqANSNMTow==} engines: {node: '>=20.0.0'} @@ -584,30 +628,58 @@ packages: resolution: {integrity: sha512-xEmd3dnyn83K6t4AJxBJA63wpEoCD45ERFG0XMTViD2E/Ohls9TLxjOWPb1PAxR9/46cKy/TImez1GoqP6xVNQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.972.7': + resolution: {integrity: sha512-p8k2ZWKJVrR3KIcBbI+/+FcWXdwe3LLgGnixsA7w8lDwWjzSVDHFp6uPeSqBt5PQpRxzak9EheJ1xTmOnHGf4g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-eventstream@3.972.3': resolution: {integrity: sha512-pbvZ6Ye/Ks6BAZPa3RhsNjHrvxU9li25PMhSdDpbX0jzdpKpAkIR65gXSNKmA/REnSdEMWSD4vKUW+5eMFzB6w==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-eventstream@3.972.4': + resolution: {integrity: sha512-0t+2Dn46cRE9iu5ynUXINBtR0wNHi/Jz3FbrqS5k3dGot2O7Ln1xCqXbJUAtGM5ZAqN77SbnpETAgVWC84DeoA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-host-header@3.972.3': resolution: {integrity: sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-host-header@3.972.4': + resolution: {integrity: sha512-4q2Vg7/zOB10huDBLjzzTwVjBpG22X3J3ief2XrJEgTaANZrNfA3/cGbCVNAibSbu/nIYA7tDk8WCdsIzDDc4Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-logger@3.972.3': resolution: {integrity: sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-logger@3.972.4': + resolution: {integrity: sha512-xFqPvTysuZAHSkdygT+ken/5rzkR7fhOoDPejAJQslZpp0XBepmCJnDOqA57ERtCTBpu8wpjTFI1ETd4S0AXEw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-recursion-detection@3.972.3': resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-recursion-detection@3.972.4': + resolution: {integrity: sha512-tVbRaayUZ7y2bOb02hC3oEPTqQf2A0HpPDwdMl1qTmye/q8Mq1F1WiIoFkQwG/YQFvbyErYIDMbYzIlxzzLtjQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-user-agent@3.972.11': resolution: {integrity: sha512-R8CvPsPHXwzIHCAza+bllY6PrctEk4lYq/SkHJz9NLoBHCcKQrbOcsfXxO6xmipSbUNIbNIUhH0lBsJGgsRdiw==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-user-agent@3.972.13': + resolution: {integrity: sha512-p1kVYbzBxRmhuOHoL/ANJPCedqUxnVgkEjxPoxt5pQv/yzppHM7aBWciYEE9TZY59M421D3GjLfZIZBoEFboVQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-websocket@3.972.6': resolution: {integrity: sha512-1DedO6N3m8zQ/vG6twNiHtsdwBgk773VdavLEbB3NXeKZDlzSK1BTviqWwvJdKx5UnIy4kGGP6WWpCEFEt/bhQ==} engines: {node: '>= 14.0.0'} + '@aws-sdk/middleware-websocket@3.972.8': + resolution: {integrity: sha512-KPUXz8lRw73Rh12/QkELxiryC9Wi9Ah1xNzFe2Vtbz2/81c2ZA0yM8er+u0iCF/SRMMhDQshLcmRNgn/ueA+gA==} + engines: {node: '>= 14.0.0'} + '@aws-sdk/nested-clients@3.993.0': resolution: {integrity: sha512-iOq86f2H67924kQUIPOAvlmMaOAvOLoDOIb66I2YqSUpMYB6ufiuJW3RlREgskxv86S5qKzMnfy/X6CqMjK6XQ==} engines: {node: '>=20.0.0'} @@ -616,10 +688,18 @@ packages: resolution: {integrity: sha512-7gq9gismVhESiRsSt0eYe1y1b6jS20LqLk+e/YSyPmGi9yHdndHQLIq73RbEJnK/QPpkQGFqq70M1mI46M1HGw==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.996.1': + resolution: {integrity: sha512-XHVLFRGkuV2gh2uwBahCt65ALMb5wMpqplXEZIvFnWOCPlk60B7h7M5J9Em243K8iICDiWY6KhBEqVGfjTqlLA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.972.3': resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.972.4': + resolution: {integrity: sha512-3GrJYv5eI65oCKveBZP7Q246dVP+tqeys9aKMB0dfX1glUWfppWlxIu52derqdNb9BX9lxYmeiaBcBIqOAYSgQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.993.0': resolution: {integrity: sha512-+35g4c+8r7sB9Sjp1KPdM8qxGn6B/shBjJtEUN4e+Edw9UEQlZKIzioOGu3UAbyE0a/s450LdLZr4wbJChtmww==} engines: {node: '>=20.0.0'} @@ -628,10 +708,18 @@ packages: resolution: {integrity: sha512-lYSadNdZZ513qCKoj/KlJ+PgCycL3n8ZNS37qLVFC0t7TbHzoxvGquu9aD2n9OCERAn43OMhQ7dXjYDYdjAXzA==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.997.0': + resolution: {integrity: sha512-UdG36F7lU9aTqGFRieEyuRUJlgEJBqKeKKekC0esH21DbUSKhPR1kZBah214kYasIaWe1hLJLaqUigoTa5hZAQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.1': resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.2': + resolution: {integrity: sha512-maTZwGsALtnAw4TJr/S6yERAosTwPduu0XhUV+SdbvRZtCOgSgk1ttL2R0XYzvkYSpvbtJocn77tBXq2AKglBw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-endpoints@3.993.0': resolution: {integrity: sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw==} engines: {node: '>=20.0.0'} @@ -640,10 +728,18 @@ packages: resolution: {integrity: sha512-aym/pjB8SLbo9w2nmkrDdAAVKVlf7CM71B9mKhjDbJTzwpSFBPHqJIMdDyj0mLumKC0aIVDr1H6U+59m9GvMFw==} engines: {node: '>=20.0.0'} + '@aws-sdk/util-endpoints@3.996.1': + resolution: {integrity: sha512-7cJyd+M5i0IoqWkJa1KFx8KNCGIx+Ywu+lT53KpqX7ReVwz03DCKUqvZ/y65vdKwo9w9/HptSAeLDluO5MpGIg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-format-url@3.972.3': resolution: {integrity: sha512-n7F2ycckcKFXa01vAsT/SJdjFHfKH9s96QHcs5gn8AaaigASICeME8WdUL9uBp8XV/OVwEt8+6gzn6KFUgQa8g==} engines: {node: '>=20.0.0'} + '@aws-sdk/util-format-url@3.972.4': + resolution: {integrity: sha512-rPm9g4WvgTz4ko5kqseIG5Vp5LUAbWBBDalm4ogHLMc0i20ChwQWqwuTUPJSu8zXn43jIM0xO2KZaYQsFJb+ew==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-locate-window@3.965.4': resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} engines: {node: '>=20.0.0'} @@ -651,6 +747,9 @@ packages: '@aws-sdk/util-user-agent-browser@3.972.3': resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} + '@aws-sdk/util-user-agent-browser@3.972.4': + resolution: {integrity: sha512-GHb+8XHv6hfLWKQKAKaSOm+vRvogg07s+FWtbR3+eCXXPSFn9XVmiYF4oypAxH7dGIvoxkVG/buHEnzYukyJiA==} + '@aws-sdk/util-user-agent-node@3.972.10': resolution: {integrity: sha512-LVXzICPlsheET+sE6tkcS47Q5HkSTrANIlqL1iFxGAY/wRQ236DX/PCAK56qMh9QJoXAfXfoRW0B0Og4R+X7Nw==} engines: {node: '>=20.0.0'} @@ -660,10 +759,23 @@ packages: aws-crt: optional: true + '@aws-sdk/util-user-agent-node@3.972.12': + resolution: {integrity: sha512-c1n3wBK6te+Vd9qU86nF8AsYuiBsxLn0AADGWyFX7vEADr3btaAg5iPQT6GYj6rvzSOEVVisvaAatOWInlJUbQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + '@aws-sdk/xml-builder@3.972.5': resolution: {integrity: sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.6': + resolution: {integrity: sha512-YrXu+UnfC8IdARa4ZkrpcyuRmA/TVgYW6Lcdtvi34NQgRjM1hTirNirN+rGb+s/kNomby8oJiIAu0KNbiZC7PA==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.3': resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} engines: {node: '>=18.0.0'} @@ -1384,20 +1496,38 @@ packages: resolution: {integrity: sha512-AC0SqEbR62PckWOyP0CmhYtfcC+Q6e1DGghwEcKpomTtmNfHTy7iTVy64mmtB2CFiN8j4rJFCqh2xJHgucUvkA==} engines: {node: '>=20.0.0'} + '@mariozechner/pi-agent-core@0.55.0': + resolution: {integrity: sha512-8RLaOpmESBSqTSpA/6E9ihxYybhrkNa5LOYNdJst57LuDSDytfvkiTXlKA4DjsHua4PKopG9p0Wgqaem+kKvCA==} + engines: {node: '>=20.0.0'} + '@mariozechner/pi-ai@0.54.1': resolution: {integrity: sha512-tiVvoNQV+3dpWgRQ1U/3bwJoDVSYwL17BE/kc00nXmaSLAPwNZoxLagtQ+HBr/rGzkq5viOgQf2dk+ud+/4UCg==} engines: {node: '>=20.0.0'} hasBin: true + '@mariozechner/pi-ai@0.55.0': + resolution: {integrity: sha512-G5rutF5h1hFZgU1W2yYktZJegKUZVDhdGCxvl7zPOonrGBczuNBKmM87VXvl1m+t9718rYMsgTSBseGN0RhYug==} + engines: {node: '>=20.0.0'} + hasBin: true + '@mariozechner/pi-coding-agent@0.54.1': resolution: {integrity: sha512-pPFrdaKZ16oIcdhZVcfWPhCDFx8PWHaACjQS9aFFcMOhLBduyKAGyf8bQtfysekl+gIbBSGDT2rgCxsOwK2bQw==} engines: {node: '>=20.0.0'} hasBin: true + '@mariozechner/pi-coding-agent@0.55.0': + resolution: {integrity: sha512-neflZvWsbFDph3RG+b3/ItfFtGaQnOFJO+N+fsnIC3BG/FEUu1IK1lcMwrM1FGGSMfJnCv7Q3Zk5MSBiRj4azQ==} + engines: {node: '>=20.0.0'} + hasBin: true + '@mariozechner/pi-tui@0.54.1': resolution: {integrity: sha512-FY8QcLlr9T276oZAwMSSPo1drg+J9Y7B+A0S9g8Jh6IFJxymKZZq29/Vit6XDziJfZIgJDraC6lpobtxgTEoFQ==} engines: {node: '>=20.0.0'} + '@mariozechner/pi-tui@0.55.0': + resolution: {integrity: sha512-qFdBsA0CTIQbUlN5hp1yJOSgJJiuTegx+oNPzpHxaMMBPjwMuh3Y8szBqE/2HxroA6mGSQfp/fzuPinTK1+Iyg==} + engines: {node: '>=20.0.0'} + '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==} engines: {node: '>= 22'} @@ -1931,260 +2061,260 @@ packages: '@oxc-project/types@0.112.0': resolution: {integrity: sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ==} - '@oxfmt/binding-android-arm-eabi@0.34.0': - resolution: {integrity: sha512-sqkqjh/Z38l+duOb1HtVqJTAj1grt2ttkobCopC/72+a4Xxz4xUgZPFyQ4HxrYMvyqO/YA0tvM1QbfOu70Gk1Q==} + '@oxfmt/binding-android-arm-eabi@0.35.0': + resolution: {integrity: sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.34.0': - resolution: {integrity: sha512-1KRCtasHcVcGOMwfOP9d5Bus2NFsN8yAYM5cBwi8LBg5UtXC3C49WHKrlEa8iF1BjOS6CR2qIqiFbGoA0DJQNQ==} + '@oxfmt/binding-android-arm64@0.35.0': + resolution: {integrity: sha512-/O+EbuAJYs6nde/anv+aID6uHsGQApyE9JtYBo/79KyU8e6RBN3DMbT0ix97y1SOnCglurmL2iZ+hlohjP2PnQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.34.0': - resolution: {integrity: sha512-b+Rmw9Bva6e/7PBES2wLO8sEU7Mi0+/Kv+pXSe/Y8i4fWNftZZlGwp8P01eECaUqpXATfSgNxdEKy7+ssVNz7g==} + '@oxfmt/binding-darwin-arm64@0.35.0': + resolution: {integrity: sha512-pGqRtqlNdn9d4VrmGUWVyQjkw79ryhI6je9y2jfqNUIZCfqceob+R97YYAoG7C5TFyt8ILdLVoN+L2vw/hSFyA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.34.0': - resolution: {integrity: sha512-QGjpevWzf1T9COEokZEWt80kPOtthW1zhRbo7x4Qoz646eTTfi6XsHG2uHeDWJmTbgBoJZPMgj2TAEV/ppEZaA==} + '@oxfmt/binding-darwin-x64@0.35.0': + resolution: {integrity: sha512-8GmsDcSozTPjrCJeGpp+sCmS9+9V5yRrdEZ1p/sTWxPG5nYeAfSLuS0nuEYjXSO+CtdSbStIW6dxa+4NM58yRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.34.0': - resolution: {integrity: sha512-VMSaC02cG75qL59M9M/szEaqq/RsLfgpzQ4nqUu8BUnX1zkiZIW2gTpUv3ZJ6qpWnHxIlAXiRZjQwmcwpvtbcg==} + '@oxfmt/binding-freebsd-x64@0.35.0': + resolution: {integrity: sha512-QyfKfTe0ytHpFKHAcHCGQEzN45QSqq1AHJOYYxQMgLM3KY4xu8OsXHpCnINjDsV4XGnQzczJDU9e04Zmd8XqIQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.34.0': - resolution: {integrity: sha512-Klm367PFJhH6vYK3vdIOxFepSJZHPaBfIuqwxdkOcfSQ4qqc/M8sgK0UTFnJWWTA/IkhMIh1kW6uEqiZ/xtQqg==} + '@oxfmt/binding-linux-arm-gnueabihf@0.35.0': + resolution: {integrity: sha512-u+kv3JD6P3J38oOyUaiCqgY5TNESzBRZJ5lyZQ6c2czUW2v5SIN9E/KWWa9vxoc+P8AFXQFUVrdzGy1tK+nbPQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.34.0': - resolution: {integrity: sha512-nqn0QueVXRfbN9m58/E9Zij0Ap8lzayx591eWBYn0sZrGzY1IRv9RYS7J/1YUXbb0Ugedo0a8qIWzUHU9bWQuA==} + '@oxfmt/binding-linux-arm-musleabihf@0.35.0': + resolution: {integrity: sha512-1NiZroCiV57I7Pf8kOH4XGR366kW5zir3VfSMBU2D0V14GpYjiYmPYFAoJboZvp8ACnZKUReWyMkNKSa5ad58A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.34.0': - resolution: {integrity: sha512-DDn+dcqW+sMTCEjvLoQvC/VWJjG7h8wcdN/J+g7ZTdf/3/Dx730pQElxPPGsCXPhprb11OsPyMp5FwXjMY3qvA==} + '@oxfmt/binding-linux-arm64-gnu@0.35.0': + resolution: {integrity: sha512-7Q0Xeg7ZnW2nxnZ4R7aF6DEbCFls4skgHZg+I63XitpNvJCbVIU8MFOTZlvZGRsY9+rPgWPQGeUpLHlyx0wvMA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxfmt/binding-linux-arm64-musl@0.34.0': - resolution: {integrity: sha512-H+F8+71gHQoGTFPPJ6z4dD0Fzfzi0UP8Zx94h5kUmIFThLvMq5K1Y/bUUubiXwwHfwb5C3MPjUpYijiy0rj51Q==} + '@oxfmt/binding-linux-arm64-musl@0.35.0': + resolution: {integrity: sha512-5Okqi+uhYFxwKz8hcnUftNNwdm8BCkf6GSCbcz9xJxYMm87k1E4p7PEmAAbhLTk7cjSdDre6TDL0pDzNX+Y22Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxfmt/binding-linux-ppc64-gnu@0.34.0': - resolution: {integrity: sha512-dIGnzTNhCXqQD5pzBwduLg8pClm+t8R53qaE9i5h8iua1iaFAJyLffh4847CNZSlASb7gn1Ofuv7KoG/EpoGZg==} + '@oxfmt/binding-linux-ppc64-gnu@0.35.0': + resolution: {integrity: sha512-9k66pbZQXM/lBJWys3Xbc5yhl4JexyfqkEf/tvtq8976VIJnLAAL3M127xHA3ifYSqxdVHfVGTg84eiBHCGcNw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@oxfmt/binding-linux-riscv64-gnu@0.34.0': - resolution: {integrity: sha512-FGQ2GTTooilDte/ogwWwkHuuL3lGtcE3uKM2EcC7kOXNWdUfMY6Jx3JCodNVVbFoybv4A+HuCj8WJji2uu1Ceg==} + '@oxfmt/binding-linux-riscv64-gnu@0.35.0': + resolution: {integrity: sha512-aUcY9ofKPtjO52idT6t0SAQvEF6ctjzUQa1lLp7GDsRpSBvuTrBQGeq0rYKz3gN8dMIQ7mtMdGD9tT4LhR8jAQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxfmt/binding-linux-riscv64-musl@0.34.0': - resolution: {integrity: sha512-2dGbGneJ7ptOIVKMwEIHdCkdZEomh74X3ggo4hCzEXL/rl9HwfsZDR15MkqfQqAs6nVXMvtGIOMxjDYa5lwKaA==} + '@oxfmt/binding-linux-riscv64-musl@0.35.0': + resolution: {integrity: sha512-C6yhY5Hvc2sGM+mCPek9ZLe5xRUOC/BvhAt2qIWFAeXMn4il04EYIjl3DsWiJr0xDMTJhvMOmD55xTRPlNp39w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxfmt/binding-linux-s390x-gnu@0.34.0': - resolution: {integrity: sha512-cCtGgmrTrxq3OeSG0UAO+w6yLZTMeOF4XM9SAkNrRUxYhRQELSDQ/iNPCLyHhYNi38uHJQbS5RQweLUDpI4ajA==} + '@oxfmt/binding-linux-s390x-gnu@0.35.0': + resolution: {integrity: sha512-RG2hlvOMK4OMZpO3mt8MpxLQ0AAezlFqhn5mI/g5YrVbPFyoCv9a34AAvbSJS501ocOxlFIRcKEuw5hFvddf9g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@oxfmt/binding-linux-x64-gnu@0.34.0': - resolution: {integrity: sha512-7AvMzmeX+k7GdgitXp99GQoIV/QZIpAS7rwxQvC/T541yWC45nwvk4mpnU8N+V6dE5SPEObnqfhCjO80s7qIsg==} + '@oxfmt/binding-linux-x64-gnu@0.35.0': + resolution: {integrity: sha512-wzmh90Pwvqj9xOKHJjkQYBpydRkaXG77ZvDz+iFDRRQpnqIEqGm5gmim2s6vnZIkDGsvKCuTdtxm0GFmBjM1+w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxfmt/binding-linux-x64-musl@0.34.0': - resolution: {integrity: sha512-uNiglhcmivJo1oDMh3hoN/Z0WsbEXOpRXZdQ3W/IkOpyV8WF308jFjSC1ZxajdcNRXWej0zgge9QXba58Owt+g==} + '@oxfmt/binding-linux-x64-musl@0.35.0': + resolution: {integrity: sha512-+HCqYCJPCUy5I+b2cf+gUVaApfgtoQT3HdnSg/l7NIcLHOhKstlYaGyrFZLmUpQt4WkFbpGKZZayG6zjRU0KFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxfmt/binding-openharmony-arm64@0.34.0': - resolution: {integrity: sha512-5eFsTjCyji25j6zznzlMc+wQAZJoL9oWy576xhqd2efv+N4g1swIzuSDcb1dz4gpcVC6veWe9pAwD7HnrGjLwg==} + '@oxfmt/binding-openharmony-arm64@0.35.0': + resolution: {integrity: sha512-kFYmWfR9YL78XyO5ws+1dsxNvZoD973qfVMNFOS4e9bcHXGF7DvGC2tY5UDFwyMCeB33t3sDIuGONKggnVNSJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.34.0': - resolution: {integrity: sha512-6id8kK0t5hKfbV6LHDzRO21wRTA6ctTlKGTZIsG/mcoir0rssvaYsedUymF4HDj7tbCUlnxCX/qOajKlEuqbIw==} + '@oxfmt/binding-win32-arm64-msvc@0.35.0': + resolution: {integrity: sha512-uD/NGdM65eKNCDGyTGdO8e9n3IHX+wwuorBvEYrPJXhDXL9qz6gzddmXH8EN04ejUXUujlq4FsoSeCfbg0Y+Jg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.34.0': - resolution: {integrity: sha512-QHaz+w673mlYqn9v/+fuiKZpjkmagleXQ+NygShDv8tdHpRYX2oYhTJwwt9j1ZfVhRgza1EIUW3JmzCXmtPdhQ==} + '@oxfmt/binding-win32-ia32-msvc@0.35.0': + resolution: {integrity: sha512-oSRD2k8J2uxYDEKR2nAE/YTY9PobOEnhZgCmspHu0+yBQ665yH8lFErQVSTE7fcGJmJp/cC6322/gc8VFuQf7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.34.0': - resolution: {integrity: sha512-CXKQM/VaF+yuvGru8ktleHLJoBdjBtTFmAsLGePiESiTN0NjCI/PiaiOCfHMJ1HdP1LykvARUwMvgaN3tDhcrg==} + '@oxfmt/binding-win32-x64-msvc@0.35.0': + resolution: {integrity: sha512-WCDJjlS95NboR0ugI2BEwzt1tYvRDorDRM9Lvctls1SLyKYuNRCyrPwp1urUPFBnwgBNn9p2/gnmo7gFMySRoQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.14.2': - resolution: {integrity: sha512-03WxIXguCXf1pTmoG2C6vqRcbrU9GaJCW6uTIiQdIQq4BrJnVWZv99KEUQQRkuHK78lOLa9g7B4K58NcVcB54g==} + '@oxlint-tsgolint/darwin-arm64@0.15.0': + resolution: {integrity: sha512-d7Ch+A6hic+RYrm32+Gh1o4lOrQqnFsHi721ORdHUDBiQPea+dssKUEMwIbA6MKmCy6TVJ02sQyi24OEfCiGzw==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.14.2': - resolution: {integrity: sha512-ksMLl1cIWz3Jw+U79BhyCPdvohZcJ/xAKri5bpT6oeEM2GVnQCHBk/KZKlYrd7hZUTxz0sLnnKHE11XFnLASNQ==} + '@oxlint-tsgolint/darwin-x64@0.15.0': + resolution: {integrity: sha512-Aoai2wAkaUJqp/uEs1gml6TbaPW4YmyO5Ai/vOSkiizgHqVctjhjKqmRiWTX2xuPY94VkwOLqp+Qr3y/0qSpWQ==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.14.2': - resolution: {integrity: sha512-2BgR535w7GLxBCyQD5DR3dBzbAgiBbG5QX1kAEVzOmWxJhhGxt5lsHdHebRo7ilukYLpBDkerz0mbMErblghCQ==} + '@oxlint-tsgolint/linux-arm64@0.15.0': + resolution: {integrity: sha512-4og13a7ec4Vku5t2Y7s3zx6YJP6IKadb1uA9fOoRH6lm/wHWoCnxjcfJmKHXRZJII81WmbdJMSPxaBfwN/S68Q==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.14.2': - resolution: {integrity: sha512-TUHFyVHfbbGtnTQZbUFgwvv3NzXBgzNLKdMUJw06thpiC7u5OW5qdk4yVXIC/xeVvdl3NAqTfcT4sA32aiMubg==} + '@oxlint-tsgolint/linux-x64@0.15.0': + resolution: {integrity: sha512-9b9xzh/1Harn3a+XiKTK/8LrWw3VcqLfYp/vhV5/zAVR2Mt0d63WSp4FL+wG7DKnI2T/CbMFUFHwc7kCQjDMzQ==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.14.2': - resolution: {integrity: sha512-OfYHa/irfVggIFEC4TbawsI7Hwrttppv//sO/e00tu4b2QRga7+VHAwtCkSFWSr0+BsO4InRYVA0+pun5BinpQ==} + '@oxlint-tsgolint/win32-arm64@0.15.0': + resolution: {integrity: sha512-nNac5hewHdkk5mowOwTqB1ZD76zB/FsUiyUvdCyupq5cG54XyKqSLEp9QGbx7wFJkWCkeWmuwRed4sfpAlKaeA==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.14.2': - resolution: {integrity: sha512-5gxwbWYE2pP+pzrO4SEeYvLk4N609eAe18rVXUx+en3qtHBkU8VM2jBmMcZdIHn+G05leu4pYvwAvw6tvT9VbA==} + '@oxlint-tsgolint/win32-x64@0.15.0': + resolution: {integrity: sha512-ioAY2XLpy83E2EqOLH9p1cEgj0G2qB1lmAn0a3yFV1jHQB29LIPIKGNsu/tYCClpwmHN79pT5KZAHZOgWxxqNg==} cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.49.0': - resolution: {integrity: sha512-2WPoh/2oK9r/i2R4o4J18AOrm3HVlWiHZ8TnuCaS4dX8m5ZzRmHW0I3eLxEurQLHWVruhQN7fHgZnah+ag5iQg==} + '@oxlint/binding-android-arm-eabi@1.50.0': + resolution: {integrity: sha512-G7MRGk/6NCe+L8ntonRdZP7IkBfEpiZ/he3buLK6JkLgMHgJShXZ+BeOwADmspXez7U7F7L1Anf4xLSkLHiGTg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.49.0': - resolution: {integrity: sha512-YqJAGvNB11EzoKm1euVhZntb79alhMvWW/j12bYqdvVxn6xzEQWrEDCJg9BPo3A3tBCSUBKH7bVkAiCBqK/L1w==} + '@oxlint/binding-android-arm64@1.50.0': + resolution: {integrity: sha512-GeSuMoJWCVpovJi/e3xDSNgjeR8WEZ6MCXL6EtPiCIM2NTzv7LbflARINTXTJy2oFBYyvdf/l2PwHzYo6EdXvg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.49.0': - resolution: {integrity: sha512-WFocCRlvVkMhChCJ2qpJfp1Gj/IjvyjuifH9Pex8m8yHonxxQa3d8DZYreuDQU3T4jvSY8rqhoRqnpc61Nlbxw==} + '@oxlint/binding-darwin-arm64@1.50.0': + resolution: {integrity: sha512-w3SY5YtxGnxCHPJ8Twl3KmS9oja1gERYk3AMoZ7Hv8P43ZtB6HVfs02TxvarxfL214Tm3uzvc2vn+DhtUNeKnw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.49.0': - resolution: {integrity: sha512-BN0KniwvehbUfYztOMwEDkYoojGm/narf5oJf+/ap+6PnzMeWLezMaVARNIS0j3OdMkjHTEP8s3+GdPJ7WDywQ==} + '@oxlint/binding-darwin-x64@1.50.0': + resolution: {integrity: sha512-hNfogDqy7tvmllXKBSlHo6k5x7dhTUVOHbMSE15CCAcXzmqf5883aPvBYPOq9AE7DpDUQUZ1kVE22YbiGW+tuw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.49.0': - resolution: {integrity: sha512-SnkAc/DPIY6joMCiP/+53Q+N2UOGMU6ULvbztpmvPJNF/jYPGhNbKtN982uj2Gs6fpbxYkmyj08QnpkD4fbHJA==} + '@oxlint/binding-freebsd-x64@1.50.0': + resolution: {integrity: sha512-ykZevOWEyu0nsxolA911ucxpEv0ahw8jfEeGWOwwb/VPoE4xoexuTOAiPNlWZNJqANlJl7yp8OyzCtXTUAxotw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.49.0': - resolution: {integrity: sha512-6Z3EzRvpQVIpO7uFhdiGhdE8Mh3S2VWKLL9xuxVqD6fzPhyI3ugthpYXlCChXzO8FzcYIZ3t1+Kau+h2NY1hqA==} + '@oxlint/binding-linux-arm-gnueabihf@1.50.0': + resolution: {integrity: sha512-hif3iDk7vo5GGJ4OLCCZAf2vjnU9FztGw4L0MbQL0M2iY9LKFtDMMiQAHmkF0PQGQMVbTYtPdXCLKVgdkiqWXQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.49.0': - resolution: {integrity: sha512-wdjXaQYAL/L25732mLlngfst4Jdmi/HLPVHb3yfCoP5mE3lO/pFFrmOJpqWodgv29suWY74Ij+RmJ/YIG5VuzQ==} + '@oxlint/binding-linux-arm-musleabihf@1.50.0': + resolution: {integrity: sha512-dVp9iSssiGAnTNey2Ruf6xUaQhdnvcFOJyRWd/mu5o2jVbFK15E5fbWGeFRfmuobu5QXuROtFga44+7DOS3PLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.49.0': - resolution: {integrity: sha512-oSHpm8zmSvAG1BWUumbDRSg7moJbnwoEXKAkwDf/xTQJOzvbUknq95NVQdw/AduZr5dePftalB8rzJNGBogUMg==} + '@oxlint/binding-linux-arm64-gnu@1.50.0': + resolution: {integrity: sha512-1cT7yz2HA910CKA9NkH1ZJo50vTtmND2fkoW1oyiSb0j6WvNtJ0Wx2zoySfXWc/c+7HFoqRK5AbEoL41LOn9oA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxlint/binding-linux-arm64-musl@1.49.0': - resolution: {integrity: sha512-xeqkMOARgGBlEg9BQuPDf6ZW711X6BT5qjDyeM5XNowCJeTSdmMhpePJjTEiVbbr3t21sIlK8RE6X5bc04nWyQ==} + '@oxlint/binding-linux-arm64-musl@1.50.0': + resolution: {integrity: sha512-++B3k/HEPFVlj89cOz8kWfQccMZB/aWL9AhsW7jPIkG++63Mpwb2cE9XOEsd0PATbIan78k2Gky+09uWM1d/gQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxlint/binding-linux-ppc64-gnu@1.49.0': - resolution: {integrity: sha512-uvcqRO6PnlJGbL7TeePhTK5+7/JXbxGbN+C6FVmfICDeeRomgQqrfVjf0lUrVpUU8ii8TSkIbNdft3M+oNlOsQ==} + '@oxlint/binding-linux-ppc64-gnu@1.50.0': + resolution: {integrity: sha512-Z9b/KpFMkx66w3gVBqjIC1AJBTZAGoI9+U+K5L4QM0CB/G0JSNC1es9b3Y0Vcrlvtdn8A+IQTkYjd/Q0uCSaZw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@oxlint/binding-linux-riscv64-gnu@1.49.0': - resolution: {integrity: sha512-Dw1HkdXAwHNH+ZDserHP2RzXQmhHtpsYYI0hf8fuGAVCIVwvS6w1+InLxpPMY25P8ASRNiFN3hADtoh6lI+4lg==} + '@oxlint/binding-linux-riscv64-gnu@1.50.0': + resolution: {integrity: sha512-jvmuIw8wRSohsQlFNIST5uUwkEtEJmOQYr33bf/K2FrFPXHhM4KqGekI3ShYJemFS/gARVacQFgBzzJKCAyJjg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxlint/binding-linux-riscv64-musl@1.49.0': - resolution: {integrity: sha512-EPlMYaA05tJ9km/0dI9K57iuMq3Tw+nHst7TNIegAJZrBPtsOtYaMFZEaWj02HA8FI5QvSnRHMt+CI+RIhXJBQ==} + '@oxlint/binding-linux-riscv64-musl@1.50.0': + resolution: {integrity: sha512-x+UrN47oYNh90nmAAyql8eQaaRpHbDPu5guasDg10+OpszUQ3/1+1J6zFMmV4xfIEgTcUXG/oI5fxJhF4eWCNA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxlint/binding-linux-s390x-gnu@1.49.0': - resolution: {integrity: sha512-yZiQL9qEwse34aMbnMb5VqiAWfDY+fLFuoJbHOuzB1OaJZbN1MRF9Nk+W89PIpGr5DNPDipwjZb8+Q7wOywoUQ==} + '@oxlint/binding-linux-s390x-gnu@1.50.0': + resolution: {integrity: sha512-i/JLi2ljLUIVfekMj4ISmdt+Hn11wzYUdRRrkVUYsCWw7zAy5xV7X9iA+KMyM156LTFympa7s3oKBjuCLoTAUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@oxlint/binding-linux-x64-gnu@1.49.0': - resolution: {integrity: sha512-CcCDwMMXSchNkhdgvhVn3DLZ4EnBXAD8o8+gRzahg+IdSt/72y19xBgShJgadIRF0TsRcV/MhDUMwL5N/W54aQ==} + '@oxlint/binding-linux-x64-gnu@1.50.0': + resolution: {integrity: sha512-/C7brhn6c6UUPccgSPCcpLQXcp+xKIW/3sji/5VZ8/OItL3tQ2U7KalHz887UxxSQeEOmd1kY6lrpuwFnmNqOA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxlint/binding-linux-x64-musl@1.49.0': - resolution: {integrity: sha512-u3HfKV8BV6t6UCCbN0RRiyqcymhrnpunVmLFI8sEa5S/EBu+p/0bJ3D7LZ2KT6PsBbrB71SWq4DeFrskOVgIZg==} + '@oxlint/binding-linux-x64-musl@1.50.0': + resolution: {integrity: sha512-oDR1f+bGOYU8LfgtEW8XtotWGB63ghtcxk5Jm6IDTCk++rTA/IRMsjOid2iMd+1bW+nP9Mdsmcdc7VbPD3+iyQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxlint/binding-openharmony-arm64@1.49.0': - resolution: {integrity: sha512-dRDpH9fw+oeUMpM4br0taYCFpW6jQtOuEIec89rOgDA1YhqwmeRcx0XYeCv7U48p57qJ1XZHeMGM9LdItIjfzA==} + '@oxlint/binding-openharmony-arm64@1.50.0': + resolution: {integrity: sha512-4CmRGPp5UpvXyu4jjP9Tey/SrXDQLRvZXm4pb4vdZBxAzbFZkCyh0KyRy4txld/kZKTJlW4TO8N1JKrNEk+mWw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.49.0': - resolution: {integrity: sha512-6rrKe/wL9tn0qnOy76i1/0f4Dc3dtQnibGlU4HqR/brVHlVjzLSoaH0gAFnLnznh9yQ6gcFTBFOPrcN/eKPDGA==} + '@oxlint/binding-win32-arm64-msvc@1.50.0': + resolution: {integrity: sha512-Fq0M6vsGcFsSfeuWAACDhd5KJrO85ckbEfe1EGuBj+KPyJz7KeWte2fSFrFGmNKNXyhEMyx4tbgxiWRujBM2KQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.49.0': - resolution: {integrity: sha512-CXHLWAtLs2xG/aVy1OZiYJzrULlq0QkYpI6cd7VKMrab+qur4fXVE/B1Bp1m0h1qKTj5/FTGg6oU4qaXMjS/ug==} + '@oxlint/binding-win32-ia32-msvc@1.50.0': + resolution: {integrity: sha512-qTdWR9KwY/vxJGhHVIZG2eBOhidOQvOwzDxnX+jhW/zIVacal1nAhR8GLkiywW8BIFDkQKXo/zOfT+/DY+ns/w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.49.0': - resolution: {integrity: sha512-VteIelt78kwzSglOozaQcs6BCS4Lk0j+QA+hGV0W8UeyaqQ3XpbZRhDU55NW1PPvCy1tg4VXsTlEaPovqto7nQ==} + '@oxlint/binding-win32-x64-msvc@1.50.0': + resolution: {integrity: sha512-682t7npLC4G2Ca+iNlI9fhAKTcFPYYXJjwoa88H4q+u5HHHlsnL/gHULapX3iqp+A8FIJbgdylL5KMYo2LaluQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2533,6 +2663,10 @@ packages: resolution: {integrity: sha512-RoygyteJeFswxDPJjUMESn9dldWVMD2xUcHHd9DenVavSfVC6FeVnSdDerOO7m8LLvw4Q132nQM4hX8JiF7dng==} engines: {node: '>= 18', npm: '>= 8.6.0'} + '@smithy/abort-controller@4.2.10': + resolution: {integrity: sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g==} + engines: {node: '>=18.0.0'} + '@smithy/abort-controller@4.2.8': resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} engines: {node: '>=18.0.0'} @@ -2541,42 +2675,86 @@ packages: resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} engines: {node: '>=18.0.0'} + '@smithy/config-resolver@4.4.9': + resolution: {integrity: sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q==} + engines: {node: '>=18.0.0'} + '@smithy/core@3.23.2': resolution: {integrity: sha512-HaaH4VbGie4t0+9nY3tNBRSxVTr96wzIqexUa6C2qx3MPePAuz7lIxPxYtt1Wc//SPfJLNoZJzfdt0B6ksj2jA==} engines: {node: '>=18.0.0'} + '@smithy/core@3.23.6': + resolution: {integrity: sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.10': + resolution: {integrity: sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.8': resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} engines: {node: '>=18.0.0'} + '@smithy/eventstream-codec@4.2.10': + resolution: {integrity: sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-codec@4.2.8': resolution: {integrity: sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==} engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-browser@4.2.10': + resolution: {integrity: sha512-0xupsu9yj9oDVuQ50YCTS9nuSYhGlrwqdaKQel9y2Fz7LU9fNErVlw9N0o4pm4qqvWEGbSTI4HKc6XJfB30MVw==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-browser@4.2.8': resolution: {integrity: sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==} engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-config-resolver@4.3.10': + resolution: {integrity: sha512-8kn6sinrduk0yaYHMJDsNuiFpXwQwibR7n/4CDUqn4UgaG+SeBHu5jHGFdU9BLFAM7Q4/gvr9RYxBHz9/jKrhA==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-config-resolver@4.3.8': resolution: {integrity: sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==} engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-node@4.2.10': + resolution: {integrity: sha512-uUrxPGgIffnYfvIOUmBM5i+USdEBRTdh7mLPttjphgtooxQ8CtdO1p6K5+Q4BBAZvKlvtJ9jWyrWpBJYzBKsyQ==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-node@4.2.8': resolution: {integrity: sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==} engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-universal@4.2.10': + resolution: {integrity: sha512-aArqzOEvcs2dK+xQVCgLbpJQGfZihw8SD4ymhkwNTtwKbnrzdhJsFDKuMQnam2kF69WzgJYOU5eJlCx+CA32bw==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-universal@4.2.8': resolution: {integrity: sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.3.11': + resolution: {integrity: sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g==} + engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.3.9': resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} engines: {node: '>=18.0.0'} + '@smithy/hash-node@4.2.10': + resolution: {integrity: sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==} + engines: {node: '>=18.0.0'} + '@smithy/hash-node@4.2.8': resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} engines: {node: '>=18.0.0'} + '@smithy/invalid-dependency@4.2.10': + resolution: {integrity: sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org==} + engines: {node: '>=18.0.0'} + '@smithy/invalid-dependency@4.2.8': resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} engines: {node: '>=18.0.0'} @@ -2589,6 +2767,14 @@ packages: resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} engines: {node: '>=18.0.0'} + '@smithy/is-array-buffer@4.2.1': + resolution: {integrity: sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.10': + resolution: {integrity: sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-content-length@4.2.8': resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} engines: {node: '>=18.0.0'} @@ -2597,18 +2783,38 @@ packages: resolution: {integrity: sha512-L5GICFCSsNhbJ5JSKeWFGFy16Q2OhoBizb3X2DrxaJwXSEujVvjG9Jt386dpQn2t7jINglQl0b4K/Su69BdbMA==} engines: {node: '>=18.0.0'} + '@smithy/middleware-endpoint@4.4.20': + resolution: {integrity: sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-retry@4.4.33': resolution: {integrity: sha512-jLqZOdJhtIL4lnA9hXnAG6GgnJlo1sD3FqsTxm9wSfjviqgWesY/TMBVnT84yr4O0Vfe0jWoXlfFbzsBVph3WA==} engines: {node: '>=18.0.0'} + '@smithy/middleware-retry@4.4.37': + resolution: {integrity: sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.11': + resolution: {integrity: sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-serde@4.2.9': resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} engines: {node: '>=18.0.0'} + '@smithy/middleware-stack@4.2.10': + resolution: {integrity: sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-stack@4.2.8': resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} engines: {node: '>=18.0.0'} + '@smithy/node-config-provider@4.3.10': + resolution: {integrity: sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==} + engines: {node: '>=18.0.0'} + '@smithy/node-config-provider@4.3.8': resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} engines: {node: '>=18.0.0'} @@ -2617,22 +2823,46 @@ packages: resolution: {integrity: sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.4.12': + resolution: {integrity: sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.10': + resolution: {integrity: sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==} + engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.2.8': resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} engines: {node: '>=18.0.0'} + '@smithy/protocol-http@5.3.10': + resolution: {integrity: sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==} + engines: {node: '>=18.0.0'} + '@smithy/protocol-http@5.3.8': resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} engines: {node: '>=18.0.0'} + '@smithy/querystring-builder@4.2.10': + resolution: {integrity: sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==} + engines: {node: '>=18.0.0'} + '@smithy/querystring-builder@4.2.8': resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} engines: {node: '>=18.0.0'} + '@smithy/querystring-parser@4.2.10': + resolution: {integrity: sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==} + engines: {node: '>=18.0.0'} + '@smithy/querystring-parser@4.2.8': resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} engines: {node: '>=18.0.0'} + '@smithy/service-error-classification@4.2.10': + resolution: {integrity: sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg==} + engines: {node: '>=18.0.0'} + '@smithy/service-error-classification@4.2.8': resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} engines: {node: '>=18.0.0'} @@ -2641,6 +2871,14 @@ packages: resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} engines: {node: '>=18.0.0'} + '@smithy/shared-ini-file-loader@4.4.5': + resolution: {integrity: sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.10': + resolution: {integrity: sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==} + engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.3.8': resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} engines: {node: '>=18.0.0'} @@ -2649,10 +2887,22 @@ packages: resolution: {integrity: sha512-xixwBRqoeP2IUgcAl3U9dvJXc+qJum4lzo3maaJxifsZxKUYLfVfCXvhT4/jD01sRrHg5zjd1cw2Zmjr4/SuKQ==} engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.12.0': + resolution: {integrity: sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.12.0': resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} engines: {node: '>=18.0.0'} + '@smithy/types@4.13.0': + resolution: {integrity: sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.10': + resolution: {integrity: sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.8': resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} engines: {node: '>=18.0.0'} @@ -2661,14 +2911,26 @@ packages: resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} engines: {node: '>=18.0.0'} + '@smithy/util-base64@4.3.1': + resolution: {integrity: sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==} + engines: {node: '>=18.0.0'} + '@smithy/util-body-length-browser@4.2.0': resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} engines: {node: '>=18.0.0'} + '@smithy/util-body-length-browser@4.2.1': + resolution: {integrity: sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==} + engines: {node: '>=18.0.0'} + '@smithy/util-body-length-node@4.2.1': resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} engines: {node: '>=18.0.0'} + '@smithy/util-body-length-node@4.2.2': + resolution: {integrity: sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw==} + engines: {node: '>=18.0.0'} + '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} @@ -2677,30 +2939,62 @@ packages: resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} engines: {node: '>=18.0.0'} + '@smithy/util-buffer-from@4.2.1': + resolution: {integrity: sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==} + engines: {node: '>=18.0.0'} + '@smithy/util-config-provider@4.2.0': resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} engines: {node: '>=18.0.0'} + '@smithy/util-config-provider@4.2.1': + resolution: {integrity: sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==} + engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-browser@4.3.32': resolution: {integrity: sha512-092sjYfFMQ/iaPH798LY/OJFBcYu0sSK34Oy9vdixhsU36zlZu8OcYjF3TD4e2ARupyK7xaxPXl+T0VIJTEkkg==} engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-browser@4.3.36': + resolution: {integrity: sha512-R0smq7EHQXRVMxkAxtH5akJ/FvgAmNF6bUy/GwY/N20T4GrwjT633NFm0VuRpC+8Bbv8R9A0DoJ9OiZL/M3xew==} + engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-node@4.2.35': resolution: {integrity: sha512-miz/ggz87M8VuM29y7jJZMYkn7+IErM5p5UgKIf8OtqVs/h2bXr1Bt3uTsREsI/4nK8a0PQERbAPsVPVNIsG7Q==} engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-node@4.2.39': + resolution: {integrity: sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg==} + engines: {node: '>=18.0.0'} + '@smithy/util-endpoints@3.2.8': resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} engines: {node: '>=18.0.0'} + '@smithy/util-endpoints@3.3.1': + resolution: {integrity: sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg==} + engines: {node: '>=18.0.0'} + '@smithy/util-hex-encoding@4.2.0': resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} engines: {node: '>=18.0.0'} + '@smithy/util-hex-encoding@4.2.1': + resolution: {integrity: sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.10': + resolution: {integrity: sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==} + engines: {node: '>=18.0.0'} + '@smithy/util-middleware@4.2.8': resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} engines: {node: '>=18.0.0'} + '@smithy/util-retry@4.2.10': + resolution: {integrity: sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A==} + engines: {node: '>=18.0.0'} + '@smithy/util-retry@4.2.8': resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} engines: {node: '>=18.0.0'} @@ -2709,10 +3003,18 @@ packages: resolution: {integrity: sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==} engines: {node: '>=18.0.0'} + '@smithy/util-stream@4.5.15': + resolution: {integrity: sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw==} + engines: {node: '>=18.0.0'} + '@smithy/util-uri-escape@4.2.0': resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} engines: {node: '>=18.0.0'} + '@smithy/util-uri-escape@4.2.1': + resolution: {integrity: sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==} + engines: {node: '>=18.0.0'} + '@smithy/util-utf8@2.3.0': resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} @@ -2721,10 +3023,18 @@ packages: resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} engines: {node: '>=18.0.0'} + '@smithy/util-utf8@4.2.1': + resolution: {integrity: sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==} + engines: {node: '>=18.0.0'} + '@smithy/uuid@1.1.0': resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} + '@smithy/uuid@1.1.1': + resolution: {integrity: sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==} + engines: {node: '>=18.0.0'} + '@snazzah/davey-android-arm-eabi@0.1.9': resolution: {integrity: sha512-Dq0WyeVGBw+uQbisV/6PeCQV2ndJozfhZqiNIfQxu6ehIdXB7iHILv+oY+AQN2n+qxiFmLh/MOX9RF+pIWdPbA==} engines: {node: '>= 10'} @@ -2982,43 +3292,43 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260222.1': - resolution: {integrity: sha512-aXfK/s3QlbzXvZoFQ07KJDNx86q61nCITSreqLytnqjhjsXUUuMACsxjy/YsReLG2bdii+mHTA2WB2IB0LKKGA==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260224.1': + resolution: {integrity: sha512-9VHXRhB7sM5DFqdlKaeDww8vuklgfzhYCjBazLCEnuFvb4J+rJ1DodLykc2bL+6kE8k6sdhYi3x8ipfbjtO44g==} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260222.1': - resolution: {integrity: sha512-+bHnCeONX47pmVXTt6kuwxiLayDVqkLtshjqpqthXMWFFGk+1K/5ASbFEb2FumSABgB9hQ/xqkjj5QHUgGmbPg==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260224.1': + resolution: {integrity: sha512-uCHipPRcIhHnvb7lAM29MQ1QT9pZ+uirqtH630aOMFm8VG3j8mkxVM9iGRLx829n38DMSDLjc3joCrQO3+sDcQ==} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260222.1': - resolution: {integrity: sha512-Usm9oJzLPqK7Z7echSSaHnmTXhr3knLXycoyVZwRrmWC33aX2efZb+XrdaV/SMhdYjYHCZ6mE60qcK4nEaXdng==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260224.1': + resolution: {integrity: sha512-yFEEq6hD2R70+lTogb211sPdCwz3H5hpYh0+YuKVMPsKo0oM8/jMvgjj2pyutmj/uCKLdbcJ9HP2vJ/13Szbcg==} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260222.1': - resolution: {integrity: sha512-bavfJlI3JNH2F/7BX0drZ4JCSjLsCc2Dy5e2s6pc2wuLIzJ6hIjFaXIeB9TDbVYJE+MlLf6rtQF9nP9iSsgk9g==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260224.1': + resolution: {integrity: sha512-cEWSRQ8b+CXdMJvoG18IjNTvBo+qT22B5imqm6nAssMpyHHQb62PvZGnrA8mPRQNPzLpa5F956j8GwAjyP8hBQ==} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260222.1': - resolution: {integrity: sha512-JaOwNBJ2nA0C/MBfMXilrVNv+hUpIzs7JtpSgpOsXa3Hq7BL2rnoO6WMuCo8IHz7v8+Lr+MPJufXVEHfrOtf5A==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260224.1': + resolution: {integrity: sha512-zGz5kVcCeBRheQwA4jVTAxtbLsBsTkp9AEvWK5AlyCs1rQCUQobBhtx37X4VEmxn4ekIDMxYgaZdlZb7/PGp8w==} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260222.1': - resolution: {integrity: sha512-Mngr3qdeO7Ey3DtsHe4oqIghXYcjOr9pVQtKXbijfT0slRtVPeF1TmEb/eH+Z+LsY1SOW8c/Cig1G4NDXZnghw==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260224.1': + resolution: {integrity: sha512-A0f9ZDQqKvGk/an59HuAJuzoI/wMyrgTd69oX9gFCx7+5E/ajSdgv0Eg1Fco+nyLfT/UVM0CV3ERyWrKzx277w==} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260222.1': - resolution: {integrity: sha512-8Gps/FPcQiyoHeDhRY3RXhJSJwQQuUIP5lepYO3+2xvCPPeeNBoOueiLoGKxno4CYbS4O2fPdVmymboX0ApjZA==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260224.1': + resolution: {integrity: sha512-Se9JrcMdVLeDYMLn+CKEV3qy1yiildb5N23USGvnC9siNFalz8tVgd589dhRP+ywDhXnbIsZiFKDrZF/7B4wSQ==} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260222.1': - resolution: {integrity: sha512-Uxon0iNhNqH/HkWvKmTmr7d5TJp6yomoyFHNpLIEghy91/DNWEtKMuLjNDYPFcoNxWpuJW9vuWTWeu3mcqT94Q==} + '@typescript/native-preview@7.0.0-dev.20260224.1': + resolution: {integrity: sha512-PU0zBXLvz6RKxbIubT66RCnJXgScdDIhfmNMkvRhOnX/C4SZom5TFSn7BEHC3w8JPj7OSz5OYoubtV1Haty2GA==} hasBin: true '@typespec/ts-http-runtime@0.3.3': @@ -3287,8 +3597,8 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} - basic-ftp@5.1.0: - resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} + basic-ftp@5.2.0: + resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} bcrypt-pbkdf@1.0.2: @@ -3914,8 +4224,8 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - google-auth-library@10.5.0: - resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + google-auth-library@10.6.1: + resolution: {integrity: sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==} engines: {node: '>=18'} google-logging-utils@1.1.3: @@ -3933,10 +4243,6 @@ packages: resolution: {integrity: sha512-ssuE7fc1AwqlUxHr931OCVW3fU+oFDjHZGgvIedPKXfTdjXvzP19xifvVGCnPtYVUig1Kz+gwxe4A9M5WdkT4Q==} engines: {node: ^12.20.0 || >=14.13.1} - gtoken@8.0.0: - resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} - engines: {node: '>=18'} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -4701,8 +5007,8 @@ packages: zod: optional: true - openai@6.22.0: - resolution: {integrity: sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw==} + openai@6.25.0: + resolution: {integrity: sha512-mEh6VZ2ds2AGGokWARo18aPISI1OhlgdEIC1ewhkZr8pSIT31dec0ecr9Nhxx0JlybyOgoAT1sWeKtwPZzJyww==} hasBin: true peerDependencies: ws: ^8.18.0 @@ -4727,6 +5033,9 @@ packages: opusscript@0.0.8: resolution: {integrity: sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==} + opusscript@0.1.1: + resolution: {integrity: sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==} + ora@8.2.0: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} @@ -4735,17 +5044,17 @@ packages: resolution: {integrity: sha512-4/8JfsetakdeEa4vAYV45FW20aY+B/+K8NEXp5Eiar3wR8726whgHrbSg5Ar/ZY1FLJ/AGtUqV7W2IVF+Gvp9A==} engines: {node: '>=20'} - oxfmt@0.34.0: - resolution: {integrity: sha512-t+zTE4XGpzPTK+Zk9gSwcJcFi4pqjl6PwO/ZxPBJiJQ2XCKMucwjPlHxvPHyVKJtkMSyrDGfQ7Ntg/hUr4OgHQ==} + oxfmt@0.35.0: + resolution: {integrity: sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - oxlint-tsgolint@0.14.2: - resolution: {integrity: sha512-XJsFIQwnYJgXFlNDz2MncQMWYxwnfy4BCy73mdiFN/P13gEZrAfBU4Jmz2XXFf9UG0wPILdi7hYa6t0KmKQLhw==} + oxlint-tsgolint@0.15.0: + resolution: {integrity: sha512-iwvFmhKQVZzVTFygUVI4t2S/VKEm+Mqkw3jQRJwfDuTcUYI5LCIYzdO5Dbuv4mFOkXZCcXaRRh0m+uydB5xdqw==} hasBin: true - oxlint@1.49.0: - resolution: {integrity: sha512-YZffp0gM+63CJoRhHjtjRnwKtAgUnXM6j63YQ++aigji2NVvLGsUlrXo9gJUXZOdcbfShLYtA6RuTu8GZ4lzOQ==} + oxlint@1.50.0: + resolution: {integrity: sha512-iSJ4IZEICBma8cZX7kxIIz9PzsYLF2FaLAYN6RKu7VwRVKdu7RIgpP99bTZaGl//Yao7fsaGZLSEo5xBrI5ReQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -5784,7 +6093,7 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.2 tslib: 2.8.1 '@aws-crypto/sha256-browser@5.2.0': @@ -5792,7 +6101,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.2 '@aws-sdk/util-locate-window': 3.965.4 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -5800,7 +6109,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.2 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -5809,7 +6118,7 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.2 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -5865,6 +6174,58 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-bedrock-runtime@3.997.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.13 + '@aws-sdk/credential-provider-node': 3.972.12 + '@aws-sdk/eventstream-handler-node': 3.972.7 + '@aws-sdk/middleware-eventstream': 3.972.4 + '@aws-sdk/middleware-host-header': 3.972.4 + '@aws-sdk/middleware-logger': 3.972.4 + '@aws-sdk/middleware-recursion-detection': 3.972.4 + '@aws-sdk/middleware-user-agent': 3.972.13 + '@aws-sdk/middleware-websocket': 3.972.8 + '@aws-sdk/region-config-resolver': 3.972.4 + '@aws-sdk/token-providers': 3.997.0 + '@aws-sdk/types': 3.973.2 + '@aws-sdk/util-endpoints': 3.996.1 + '@aws-sdk/util-user-agent-browser': 3.972.4 + '@aws-sdk/util-user-agent-node': 3.972.12 + '@smithy/config-resolver': 4.4.9 + '@smithy/core': 3.23.6 + '@smithy/eventstream-serde-browser': 4.2.10 + '@smithy/eventstream-serde-config-resolver': 4.3.10 + '@smithy/eventstream-serde-node': 4.2.10 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/hash-node': 4.2.10 + '@smithy/invalid-dependency': 4.2.10 + '@smithy/middleware-content-length': 4.2.10 + '@smithy/middleware-endpoint': 4.4.20 + '@smithy/middleware-retry': 4.4.37 + '@smithy/middleware-serde': 4.2.11 + '@smithy/middleware-stack': 4.2.10 + '@smithy/node-config-provider': 4.3.10 + '@smithy/node-http-handler': 4.4.12 + '@smithy/protocol-http': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-base64': 4.3.1 + '@smithy/util-body-length-browser': 4.2.1 + '@smithy/util-body-length-node': 4.2.2 + '@smithy/util-defaults-mode-browser': 4.3.36 + '@smithy/util-defaults-mode-node': 4.2.39 + '@smithy/util-endpoints': 3.3.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-retry': 4.2.10 + '@smithy/util-stream': 4.5.15 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-bedrock@3.995.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -5910,6 +6271,51 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-bedrock@3.997.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.13 + '@aws-sdk/credential-provider-node': 3.972.12 + '@aws-sdk/middleware-host-header': 3.972.4 + '@aws-sdk/middleware-logger': 3.972.4 + '@aws-sdk/middleware-recursion-detection': 3.972.4 + '@aws-sdk/middleware-user-agent': 3.972.13 + '@aws-sdk/region-config-resolver': 3.972.4 + '@aws-sdk/token-providers': 3.997.0 + '@aws-sdk/types': 3.973.2 + '@aws-sdk/util-endpoints': 3.996.1 + '@aws-sdk/util-user-agent-browser': 3.972.4 + '@aws-sdk/util-user-agent-node': 3.972.12 + '@smithy/config-resolver': 4.4.9 + '@smithy/core': 3.23.6 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/hash-node': 4.2.10 + '@smithy/invalid-dependency': 4.2.10 + '@smithy/middleware-content-length': 4.2.10 + '@smithy/middleware-endpoint': 4.4.20 + '@smithy/middleware-retry': 4.4.37 + '@smithy/middleware-serde': 4.2.11 + '@smithy/middleware-stack': 4.2.10 + '@smithy/node-config-provider': 4.3.10 + '@smithy/node-http-handler': 4.4.12 + '@smithy/protocol-http': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-base64': 4.3.1 + '@smithy/util-body-length-browser': 4.2.1 + '@smithy/util-body-length-node': 4.2.2 + '@smithy/util-defaults-mode-browser': 4.3.36 + '@smithy/util-defaults-mode-node': 4.2.39 + '@smithy/util-endpoints': 3.3.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-retry': 4.2.10 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-sso@3.993.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -5969,6 +6375,30 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@aws-sdk/core@3.973.13': + dependencies: + '@aws-sdk/types': 3.973.2 + '@aws-sdk/xml-builder': 3.972.6 + '@smithy/core': 3.23.6 + '@smithy/node-config-provider': 4.3.10 + '@smithy/property-provider': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/signature-v4': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.11': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/types': 3.973.2 + '@smithy/property-provider': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.9': dependencies: '@aws-sdk/core': 3.973.11 @@ -5990,6 +6420,38 @@ snapshots: '@smithy/util-stream': 4.5.12 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.13': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/types': 3.973.2 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/node-http-handler': 4.4.12 + '@smithy/property-provider': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/util-stream': 4.5.15 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.11': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/credential-provider-env': 3.972.11 + '@aws-sdk/credential-provider-http': 3.972.13 + '@aws-sdk/credential-provider-login': 3.972.11 + '@aws-sdk/credential-provider-process': 3.972.11 + '@aws-sdk/credential-provider-sso': 3.972.11 + '@aws-sdk/credential-provider-web-identity': 3.972.11 + '@aws-sdk/nested-clients': 3.996.1 + '@aws-sdk/types': 3.973.2 + '@smithy/credential-provider-imds': 4.2.10 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-ini@3.972.9': dependencies: '@aws-sdk/core': 3.973.11 @@ -6009,6 +6471,19 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-login@3.972.11': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/nested-clients': 3.996.1 + '@aws-sdk/types': 3.973.2 + '@smithy/property-provider': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-login@3.972.9': dependencies: '@aws-sdk/core': 3.973.11 @@ -6039,6 +6514,32 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-node@3.972.12': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.11 + '@aws-sdk/credential-provider-http': 3.972.13 + '@aws-sdk/credential-provider-ini': 3.972.11 + '@aws-sdk/credential-provider-process': 3.972.11 + '@aws-sdk/credential-provider-sso': 3.972.11 + '@aws-sdk/credential-provider-web-identity': 3.972.11 + '@aws-sdk/types': 3.973.2 + '@smithy/credential-provider-imds': 4.2.10 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.11': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/types': 3.973.2 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.9': dependencies: '@aws-sdk/core': 3.973.11 @@ -6048,6 +6549,19 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.11': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/nested-clients': 3.996.1 + '@aws-sdk/token-providers': 3.997.0 + '@aws-sdk/types': 3.973.2 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-sso@3.972.9': dependencies: '@aws-sdk/client-sso': 3.993.0 @@ -6061,6 +6575,18 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.11': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/nested-clients': 3.996.1 + '@aws-sdk/types': 3.973.2 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.9': dependencies: '@aws-sdk/core': 3.973.11 @@ -6080,6 +6606,13 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/eventstream-handler-node@3.972.7': + dependencies: + '@aws-sdk/types': 3.973.2 + '@smithy/eventstream-codec': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/middleware-eventstream@3.972.3': dependencies: '@aws-sdk/types': 3.973.1 @@ -6087,6 +6620,13 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/middleware-eventstream@3.972.4': + dependencies: + '@aws-sdk/types': 3.973.2 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/middleware-host-header@3.972.3': dependencies: '@aws-sdk/types': 3.973.1 @@ -6094,12 +6634,25 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/middleware-host-header@3.972.4': + dependencies: + '@aws-sdk/types': 3.973.2 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/middleware-logger@3.972.3': dependencies: '@aws-sdk/types': 3.973.1 '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/middleware-logger@3.972.4': + dependencies: + '@aws-sdk/types': 3.973.2 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/middleware-recursion-detection@3.972.3': dependencies: '@aws-sdk/types': 3.973.1 @@ -6108,6 +6661,14 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/middleware-recursion-detection@3.972.4': + dependencies: + '@aws-sdk/types': 3.973.2 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/middleware-user-agent@3.972.11': dependencies: '@aws-sdk/core': 3.973.11 @@ -6118,6 +6679,16 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/middleware-user-agent@3.972.13': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/types': 3.973.2 + '@aws-sdk/util-endpoints': 3.996.1 + '@smithy/core': 3.23.6 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/middleware-websocket@3.972.6': dependencies: '@aws-sdk/types': 3.973.1 @@ -6133,6 +6704,21 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@aws-sdk/middleware-websocket@3.972.8': + dependencies: + '@aws-sdk/types': 3.973.2 + '@aws-sdk/util-format-url': 3.972.4 + '@smithy/eventstream-codec': 4.2.10 + '@smithy/eventstream-serde-browser': 4.2.10 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/protocol-http': 5.3.10 + '@smithy/signature-v4': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-hex-encoding': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.993.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -6219,6 +6805,49 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/nested-clients@3.996.1': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.13 + '@aws-sdk/middleware-host-header': 3.972.4 + '@aws-sdk/middleware-logger': 3.972.4 + '@aws-sdk/middleware-recursion-detection': 3.972.4 + '@aws-sdk/middleware-user-agent': 3.972.13 + '@aws-sdk/region-config-resolver': 3.972.4 + '@aws-sdk/types': 3.973.2 + '@aws-sdk/util-endpoints': 3.996.1 + '@aws-sdk/util-user-agent-browser': 3.972.4 + '@aws-sdk/util-user-agent-node': 3.972.12 + '@smithy/config-resolver': 4.4.9 + '@smithy/core': 3.23.6 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/hash-node': 4.2.10 + '@smithy/invalid-dependency': 4.2.10 + '@smithy/middleware-content-length': 4.2.10 + '@smithy/middleware-endpoint': 4.4.20 + '@smithy/middleware-retry': 4.4.37 + '@smithy/middleware-serde': 4.2.11 + '@smithy/middleware-stack': 4.2.10 + '@smithy/node-config-provider': 4.3.10 + '@smithy/node-http-handler': 4.4.12 + '@smithy/protocol-http': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-base64': 4.3.1 + '@smithy/util-body-length-browser': 4.2.1 + '@smithy/util-body-length-node': 4.2.2 + '@smithy/util-defaults-mode-browser': 4.3.36 + '@smithy/util-defaults-mode-node': 4.2.39 + '@smithy/util-endpoints': 3.3.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-retry': 4.2.10 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/region-config-resolver@3.972.3': dependencies: '@aws-sdk/types': 3.973.1 @@ -6227,6 +6856,14 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/region-config-resolver@3.972.4': + dependencies: + '@aws-sdk/types': 3.973.2 + '@smithy/config-resolver': 4.4.9 + '@smithy/node-config-provider': 4.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.993.0': dependencies: '@aws-sdk/core': 3.973.11 @@ -6251,11 +6888,28 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/token-providers@3.997.0': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/nested-clients': 3.996.1 + '@aws-sdk/types': 3.973.2 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/types@3.973.1': dependencies: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/types@3.973.2': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/util-endpoints@3.993.0': dependencies: '@aws-sdk/types': 3.973.1 @@ -6272,6 +6926,14 @@ snapshots: '@smithy/util-endpoints': 3.2.8 tslib: 2.8.1 + '@aws-sdk/util-endpoints@3.996.1': + dependencies: + '@aws-sdk/types': 3.973.2 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-endpoints': 3.3.1 + tslib: 2.8.1 + '@aws-sdk/util-format-url@3.972.3': dependencies: '@aws-sdk/types': 3.973.1 @@ -6279,17 +6941,31 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.965.4': + '@aws-sdk/util-format-url@3.972.4': dependencies: + '@aws-sdk/types': 3.973.2 + '@smithy/querystring-builder': 4.2.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.972.3': + '@aws-sdk/util-locate-window@3.965.4': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.972.3': dependencies: '@aws-sdk/types': 3.973.1 '@smithy/types': 4.12.0 bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/util-user-agent-browser@3.972.4': + dependencies: + '@aws-sdk/types': 3.973.2 + '@smithy/types': 4.13.0 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.972.10': dependencies: '@aws-sdk/middleware-user-agent': 3.972.11 @@ -6298,12 +6974,26 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.972.12': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.13 + '@aws-sdk/types': 3.973.2 + '@smithy/node-config-provider': 4.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.5': dependencies: '@smithy/types': 4.12.0 fast-xml-parser: 5.3.6 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.6': + dependencies: + '@smithy/types': 4.13.0 + fast-xml-parser: 5.3.6 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.3': {} '@azure/abort-controller@2.1.2': @@ -6395,6 +7085,26 @@ snapshots: - opusscript - utf-8-validate + '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.1.1)': + dependencies: + '@types/node': 25.3.0 + 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.9(hono@4.11.10) + '@types/bun': 1.3.9 + '@types/ws': 8.18.1 + ws: 8.19.0 + transitivePeerDependencies: + - '@discordjs/opus' + - bufferutil + - ffmpeg-static + - hono + - node-opus + - opusscript + - utf-8-validate + '@cacheable/memory@2.0.7': dependencies: '@cacheable/utils': 2.3.4 @@ -6546,6 +7256,21 @@ snapshots: - opusscript - utf-8-validate + '@discordjs/voice@0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)': + dependencies: + '@types/ws': 8.18.1 + discord-api-types: 0.38.40 + prism-media: 1.3.5(@discordjs/opus@0.10.0)(opusscript@0.1.1) + tslib: 2.8.1 + ws: 8.19.0 + transitivePeerDependencies: + - '@discordjs/opus' + - bufferutil + - ffmpeg-static + - node-opus + - opusscript + - utf-8-validate + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -6645,7 +7370,7 @@ snapshots: '@google/genai@1.42.0': dependencies: - google-auth-library: 10.5.0 + google-auth-library: 10.6.1 p-retry: 4.6.2 protobufjs: 7.5.4 ws: 8.19.0 @@ -6877,7 +7602,7 @@ snapshots: '@larksuiteoapi/node-sdk@1.59.0': dependencies: - axios: 1.13.5(debug@4.4.3) + axios: 1.13.5 lodash.identity: 3.0.0 lodash.merge: 4.6.2 lodash.pickby: 4.6.0 @@ -6893,7 +7618,7 @@ snapshots: dependencies: '@types/node': 24.10.13 optionalDependencies: - axios: 1.13.5(debug@4.4.3) + axios: 1.13.5 transitivePeerDependencies: - debug @@ -7000,6 +7725,18 @@ snapshots: - ws - zod + '@mariozechner/pi-agent-core@0.55.0(ws@8.19.0)(zod@4.3.6)': + dependencies: + '@mariozechner/pi-ai': 0.55.0(ws@8.19.0)(zod@4.3.6) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@mariozechner/pi-ai@0.54.1(ws@8.19.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) @@ -7024,6 +7761,30 @@ snapshots: - ws - zod + '@mariozechner/pi-ai@0.55.0(ws@8.19.0)(zod@4.3.6)': + dependencies: + '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) + '@aws-sdk/client-bedrock-runtime': 3.997.0 + '@google/genai': 1.42.0 + '@mistralai/mistralai': 1.10.0 + '@sinclair/typebox': 0.34.48 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + chalk: 5.6.2 + openai: 6.10.0(ws@8.19.0)(zod@4.3.6) + partial-json: 0.1.7 + proxy-agent: 6.5.0 + undici: 7.22.0 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@mariozechner/pi-coding-agent@0.54.1(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/jiti': 2.6.5 @@ -7053,6 +7814,35 @@ snapshots: - ws - zod + '@mariozechner/pi-coding-agent@0.55.0(ws@8.19.0)(zod@4.3.6)': + dependencies: + '@mariozechner/jiti': 2.6.5 + '@mariozechner/pi-agent-core': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.55.0 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cli-highlight: 2.1.11 + diff: 8.0.3 + file-type: 21.3.0 + glob: 13.0.6 + hosted-git-info: 9.0.2 + ignore: 7.0.5 + marked: 15.0.12 + minimatch: 10.2.1 + proper-lockfile: 4.1.2 + yaml: 2.8.2 + optionalDependencies: + '@mariozechner/clipboard': 0.3.2 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@mariozechner/pi-tui@0.54.1': dependencies: '@types/mime-types': 2.1.4 @@ -7062,6 +7852,15 @@ snapshots: marked: 15.0.12 mime-types: 3.0.2 + '@mariozechner/pi-tui@0.55.0': + dependencies: + '@types/mime-types': 2.1.4 + chalk: 5.6.2 + get-east-asian-width: 1.5.0 + koffi: 2.15.1 + marked: 15.0.12 + mime-types: 3.0.2 + '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': dependencies: https-proxy-agent: 7.0.6 @@ -7082,7 +7881,7 @@ snapshots: '@azure/core-auth': 1.10.1 '@azure/msal-node': 5.0.4 '@microsoft/agents-activity': 1.3.1 - axios: 1.13.5(debug@4.4.3) + axios: 1.13.5 jsonwebtoken: 9.0.3 jwks-rsa: 3.2.2 object-path: 0.11.8 @@ -7633,136 +8432,136 @@ snapshots: '@oxc-project/types@0.112.0': {} - '@oxfmt/binding-android-arm-eabi@0.34.0': + '@oxfmt/binding-android-arm-eabi@0.35.0': optional: true - '@oxfmt/binding-android-arm64@0.34.0': + '@oxfmt/binding-android-arm64@0.35.0': optional: true - '@oxfmt/binding-darwin-arm64@0.34.0': + '@oxfmt/binding-darwin-arm64@0.35.0': optional: true - '@oxfmt/binding-darwin-x64@0.34.0': + '@oxfmt/binding-darwin-x64@0.35.0': optional: true - '@oxfmt/binding-freebsd-x64@0.34.0': + '@oxfmt/binding-freebsd-x64@0.35.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.34.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.35.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.34.0': + '@oxfmt/binding-linux-arm-musleabihf@0.35.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.34.0': + '@oxfmt/binding-linux-arm64-gnu@0.35.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.34.0': + '@oxfmt/binding-linux-arm64-musl@0.35.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.34.0': + '@oxfmt/binding-linux-ppc64-gnu@0.35.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.34.0': + '@oxfmt/binding-linux-riscv64-gnu@0.35.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.34.0': + '@oxfmt/binding-linux-riscv64-musl@0.35.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.34.0': + '@oxfmt/binding-linux-s390x-gnu@0.35.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.34.0': + '@oxfmt/binding-linux-x64-gnu@0.35.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.34.0': + '@oxfmt/binding-linux-x64-musl@0.35.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.34.0': + '@oxfmt/binding-openharmony-arm64@0.35.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.34.0': + '@oxfmt/binding-win32-arm64-msvc@0.35.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.34.0': + '@oxfmt/binding-win32-ia32-msvc@0.35.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.34.0': + '@oxfmt/binding-win32-x64-msvc@0.35.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.14.2': + '@oxlint-tsgolint/darwin-arm64@0.15.0': optional: true - '@oxlint-tsgolint/darwin-x64@0.14.2': + '@oxlint-tsgolint/darwin-x64@0.15.0': optional: true - '@oxlint-tsgolint/linux-arm64@0.14.2': + '@oxlint-tsgolint/linux-arm64@0.15.0': optional: true - '@oxlint-tsgolint/linux-x64@0.14.2': + '@oxlint-tsgolint/linux-x64@0.15.0': optional: true - '@oxlint-tsgolint/win32-arm64@0.14.2': + '@oxlint-tsgolint/win32-arm64@0.15.0': optional: true - '@oxlint-tsgolint/win32-x64@0.14.2': + '@oxlint-tsgolint/win32-x64@0.15.0': optional: true - '@oxlint/binding-android-arm-eabi@1.49.0': + '@oxlint/binding-android-arm-eabi@1.50.0': optional: true - '@oxlint/binding-android-arm64@1.49.0': + '@oxlint/binding-android-arm64@1.50.0': optional: true - '@oxlint/binding-darwin-arm64@1.49.0': + '@oxlint/binding-darwin-arm64@1.50.0': optional: true - '@oxlint/binding-darwin-x64@1.49.0': + '@oxlint/binding-darwin-x64@1.50.0': optional: true - '@oxlint/binding-freebsd-x64@1.49.0': + '@oxlint/binding-freebsd-x64@1.50.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.49.0': + '@oxlint/binding-linux-arm-gnueabihf@1.50.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.49.0': + '@oxlint/binding-linux-arm-musleabihf@1.50.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.49.0': + '@oxlint/binding-linux-arm64-gnu@1.50.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.49.0': + '@oxlint/binding-linux-arm64-musl@1.50.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.49.0': + '@oxlint/binding-linux-ppc64-gnu@1.50.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.49.0': + '@oxlint/binding-linux-riscv64-gnu@1.50.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.49.0': + '@oxlint/binding-linux-riscv64-musl@1.50.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.49.0': + '@oxlint/binding-linux-s390x-gnu@1.50.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.49.0': + '@oxlint/binding-linux-x64-gnu@1.50.0': optional: true - '@oxlint/binding-linux-x64-musl@1.49.0': + '@oxlint/binding-linux-x64-musl@1.50.0': optional: true - '@oxlint/binding-openharmony-arm64@1.49.0': + '@oxlint/binding-openharmony-arm64@1.50.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.49.0': + '@oxlint/binding-win32-arm64-msvc@1.50.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.49.0': + '@oxlint/binding-win32-ia32-msvc@1.50.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.49.0': + '@oxlint/binding-win32-x64-msvc@1.50.0': optional: true '@pinojs/redact@0.4.0': {} @@ -7983,7 +8782,7 @@ snapshots: '@slack/types': 2.20.0 '@slack/web-api': 7.14.1 '@types/express': 5.0.6 - axios: 1.13.5(debug@4.4.3) + axios: 1.13.5 express: 5.2.1 path-to-regexp: 8.3.0 raw-body: 3.0.2 @@ -8029,7 +8828,7 @@ snapshots: '@slack/types': 2.20.0 '@types/node': 25.3.0 '@types/retry': 0.12.0 - axios: 1.13.5(debug@4.4.3) + axios: 1.13.5 eventemitter3: 5.0.4 form-data: 2.5.4 is-electron: 2.2.2 @@ -8040,6 +8839,11 @@ snapshots: transitivePeerDependencies: - debug + '@smithy/abort-controller@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/abort-controller@4.2.8': dependencies: '@smithy/types': 4.12.0 @@ -8054,6 +8858,15 @@ snapshots: '@smithy/util-middleware': 4.2.8 tslib: 2.8.1 + '@smithy/config-resolver@4.4.9': + dependencies: + '@smithy/node-config-provider': 4.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-config-provider': 4.2.1 + '@smithy/util-endpoints': 3.3.1 + '@smithy/util-middleware': 4.2.10 + tslib: 2.8.1 + '@smithy/core@3.23.2': dependencies: '@smithy/middleware-serde': 4.2.9 @@ -8067,6 +8880,27 @@ snapshots: '@smithy/uuid': 1.1.0 tslib: 2.8.1 + '@smithy/core@3.23.6': + dependencies: + '@smithy/middleware-serde': 4.2.11 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-body-length-browser': 4.2.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-stream': 4.5.15 + '@smithy/util-utf8': 4.2.1 + '@smithy/uuid': 1.1.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.10': + dependencies: + '@smithy/node-config-provider': 4.3.10 + '@smithy/property-provider': 4.2.10 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.8': dependencies: '@smithy/node-config-provider': 4.3.8 @@ -8075,6 +8909,13 @@ snapshots: '@smithy/url-parser': 4.2.8 tslib: 2.8.1 + '@smithy/eventstream-codec@4.2.10': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.13.0 + '@smithy/util-hex-encoding': 4.2.1 + tslib: 2.8.1 + '@smithy/eventstream-codec@4.2.8': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -8082,29 +8923,60 @@ snapshots: '@smithy/util-hex-encoding': 4.2.0 tslib: 2.8.1 + '@smithy/eventstream-serde-browser@4.2.10': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/eventstream-serde-browser@4.2.8': dependencies: '@smithy/eventstream-serde-universal': 4.2.8 '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/eventstream-serde-config-resolver@4.3.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/eventstream-serde-config-resolver@4.3.8': dependencies: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/eventstream-serde-node@4.2.10': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/eventstream-serde-node@4.2.8': dependencies: '@smithy/eventstream-serde-universal': 4.2.8 '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/eventstream-serde-universal@4.2.10': + dependencies: + '@smithy/eventstream-codec': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/eventstream-serde-universal@4.2.8': dependencies: '@smithy/eventstream-codec': 4.2.8 '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.3.11': + dependencies: + '@smithy/protocol-http': 5.3.10 + '@smithy/querystring-builder': 4.2.10 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + tslib: 2.8.1 + '@smithy/fetch-http-handler@5.3.9': dependencies: '@smithy/protocol-http': 5.3.8 @@ -8113,6 +8985,13 @@ snapshots: '@smithy/util-base64': 4.3.0 tslib: 2.8.1 + '@smithy/hash-node@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@smithy/hash-node@4.2.8': dependencies: '@smithy/types': 4.12.0 @@ -8120,6 +8999,11 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@smithy/invalid-dependency@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/invalid-dependency@4.2.8': dependencies: '@smithy/types': 4.12.0 @@ -8133,6 +9017,16 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/is-array-buffer@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.10': + dependencies: + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/middleware-content-length@4.2.8': dependencies: '@smithy/protocol-http': 5.3.8 @@ -8150,6 +9044,17 @@ snapshots: '@smithy/util-middleware': 4.2.8 tslib: 2.8.1 + '@smithy/middleware-endpoint@4.4.20': + dependencies: + '@smithy/core': 3.23.6 + '@smithy/middleware-serde': 4.2.11 + '@smithy/node-config-provider': 4.3.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-middleware': 4.2.10 + tslib: 2.8.1 + '@smithy/middleware-retry@4.4.33': dependencies: '@smithy/node-config-provider': 4.3.8 @@ -8162,17 +9067,47 @@ snapshots: '@smithy/uuid': 1.1.0 tslib: 2.8.1 + '@smithy/middleware-retry@4.4.37': + dependencies: + '@smithy/node-config-provider': 4.3.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/service-error-classification': 4.2.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-retry': 4.2.10 + '@smithy/uuid': 1.1.1 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.11': + dependencies: + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/middleware-serde@4.2.9': dependencies: '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/middleware-stack@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/middleware-stack@4.2.8': dependencies: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/node-config-provider@4.3.10': + dependencies: + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/node-config-provider@4.3.8': dependencies: '@smithy/property-provider': 4.2.8 @@ -8188,27 +9123,60 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/node-http-handler@4.4.12': + dependencies: + '@smithy/abort-controller': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/querystring-builder': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/property-provider@4.2.8': dependencies: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/protocol-http@5.3.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/protocol-http@5.3.8': dependencies: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/querystring-builder@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + '@smithy/util-uri-escape': 4.2.1 + tslib: 2.8.1 + '@smithy/querystring-builder@4.2.8': dependencies: '@smithy/types': 4.12.0 '@smithy/util-uri-escape': 4.2.0 tslib: 2.8.1 + '@smithy/querystring-parser@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/querystring-parser@4.2.8': dependencies: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/service-error-classification@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + '@smithy/service-error-classification@4.2.8': dependencies: '@smithy/types': 4.12.0 @@ -8218,6 +9186,22 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/shared-ini-file-loader@4.4.5': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.10': + dependencies: + '@smithy/is-array-buffer': 4.2.1 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-hex-encoding': 4.2.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-uri-escape': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@smithy/signature-v4@5.3.8': dependencies: '@smithy/is-array-buffer': 4.2.0 @@ -8239,10 +9223,30 @@ snapshots: '@smithy/util-stream': 4.5.12 tslib: 2.8.1 + '@smithy/smithy-client@4.12.0': + dependencies: + '@smithy/core': 3.23.6 + '@smithy/middleware-endpoint': 4.4.20 + '@smithy/middleware-stack': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-stream': 4.5.15 + tslib: 2.8.1 + '@smithy/types@4.12.0': dependencies: tslib: 2.8.1 + '@smithy/types@4.13.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.10': + dependencies: + '@smithy/querystring-parser': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/url-parser@4.2.8': dependencies: '@smithy/querystring-parser': 4.2.8 @@ -8255,14 +9259,28 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@smithy/util-base64@4.3.1': + dependencies: + '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@smithy/util-body-length-browser@4.2.0': dependencies: tslib: 2.8.1 + '@smithy/util-body-length-browser@4.2.1': + dependencies: + tslib: 2.8.1 + '@smithy/util-body-length-node@4.2.1': dependencies: tslib: 2.8.1 + '@smithy/util-body-length-node@4.2.2': + dependencies: + tslib: 2.8.1 + '@smithy/util-buffer-from@2.2.0': dependencies: '@smithy/is-array-buffer': 2.2.0 @@ -8273,10 +9291,19 @@ snapshots: '@smithy/is-array-buffer': 4.2.0 tslib: 2.8.1 + '@smithy/util-buffer-from@4.2.1': + dependencies: + '@smithy/is-array-buffer': 4.2.1 + tslib: 2.8.1 + '@smithy/util-config-provider@4.2.0': dependencies: tslib: 2.8.1 + '@smithy/util-config-provider@4.2.1': + dependencies: + tslib: 2.8.1 + '@smithy/util-defaults-mode-browser@4.3.32': dependencies: '@smithy/property-provider': 4.2.8 @@ -8284,6 +9311,13 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/util-defaults-mode-browser@4.3.36': + dependencies: + '@smithy/property-provider': 4.2.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-defaults-mode-node@4.2.35': dependencies: '@smithy/config-resolver': 4.4.6 @@ -8294,21 +9328,52 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/util-defaults-mode-node@4.2.39': + dependencies: + '@smithy/config-resolver': 4.4.9 + '@smithy/credential-provider-imds': 4.2.10 + '@smithy/node-config-provider': 4.3.10 + '@smithy/property-provider': 4.2.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-endpoints@3.2.8': dependencies: '@smithy/node-config-provider': 4.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/util-endpoints@3.3.1': + dependencies: + '@smithy/node-config-provider': 4.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-hex-encoding@4.2.0': dependencies: tslib: 2.8.1 + '@smithy/util-hex-encoding@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-middleware@4.2.8': dependencies: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/util-retry@4.2.10': + dependencies: + '@smithy/service-error-classification': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-retry@4.2.8': dependencies: '@smithy/service-error-classification': 4.2.8 @@ -8326,10 +9391,25 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@smithy/util-stream@4.5.15': + dependencies: + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/node-http-handler': 4.4.12 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-hex-encoding': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@smithy/util-uri-escape@4.2.0': dependencies: tslib: 2.8.1 + '@smithy/util-uri-escape@4.2.1': + dependencies: + tslib: 2.8.1 + '@smithy/util-utf8@2.3.0': dependencies: '@smithy/util-buffer-from': 2.2.0 @@ -8340,10 +9420,19 @@ snapshots: '@smithy/util-buffer-from': 4.2.0 tslib: 2.8.1 + '@smithy/util-utf8@4.2.1': + dependencies: + '@smithy/util-buffer-from': 4.2.1 + tslib: 2.8.1 + '@smithy/uuid@1.1.0': dependencies: tslib: 2.8.1 + '@smithy/uuid@1.1.1': + dependencies: + tslib: 2.8.1 + '@snazzah/davey-android-arm-eabi@0.1.9': optional: true @@ -8629,36 +9718,36 @@ snapshots: dependencies: '@types/node': 25.3.0 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260222.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260224.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260222.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260224.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260222.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260224.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260222.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260224.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260222.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260224.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260222.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260224.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260222.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260224.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260222.1': + '@typescript/native-preview@7.0.0-dev.20260224.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260222.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260222.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260222.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260222.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260222.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260222.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260222.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260224.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260224.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260224.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260224.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260224.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260224.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260224.1 '@typespec/ts-http-runtime@0.3.3': dependencies: @@ -8982,6 +10071,14 @@ snapshots: aws4@1.13.2: {} + axios@1.13.5: + dependencies: + follow-redirects: 1.15.11 + form-data: 2.5.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axios@1.13.5(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) @@ -8998,7 +10095,7 @@ snapshots: dependencies: safe-buffer: 5.1.2 - basic-ftp@5.1.0: {} + basic-ftp@5.2.0: {} bcrypt-pbkdf@1.0.2: dependencies: @@ -9551,6 +10648,8 @@ snapshots: flatbuffers@24.12.23: {} + follow-redirects@1.15.11: {} + follow-redirects@1.15.11(debug@4.4.3): optionalDependencies: debug: 4.4.3 @@ -9669,7 +10768,7 @@ snapshots: get-uri@6.0.5: dependencies: - basic-ftp: 5.1.0 + basic-ftp: 5.2.0 data-uri-to-buffer: 6.0.2 debug: 4.4.3 transitivePeerDependencies: @@ -9706,14 +10805,13 @@ snapshots: path-is-absolute: 1.0.1 optional: true - google-auth-library@10.5.0: + google-auth-library@10.6.1: dependencies: base64-js: 1.5.1 ecdsa-sig-formatter: 1.0.11 gaxios: 7.1.3 gcp-metadata: 8.1.2 google-logging-utils: 1.1.3 - gtoken: 8.0.0 jws: 4.0.1 transitivePeerDependencies: - supports-color @@ -9734,13 +10832,6 @@ snapshots: - encoding - supports-color - gtoken@8.0.0: - dependencies: - gaxios: 7.1.3 - jws: 4.0.1 - transitivePeerDependencies: - - supports-color - has-flag@4.0.0: {} has-own@1.0.1: {} @@ -10532,7 +11623,7 @@ snapshots: ws: 8.19.0 zod: 4.3.6 - openai@6.22.0(ws@8.19.0)(zod@4.3.6): + openai@6.25.0(ws@8.19.0)(zod@4.3.6): optionalDependencies: ws: 8.19.0 zod: 4.3.6 @@ -10620,6 +11711,8 @@ snapshots: opusscript@0.0.8: {} + opusscript@0.1.1: {} + ora@8.2.0: dependencies: chalk: 5.6.2 @@ -10634,61 +11727,61 @@ snapshots: osc-progress@0.3.0: {} - oxfmt@0.34.0: + oxfmt@0.35.0: dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.34.0 - '@oxfmt/binding-android-arm64': 0.34.0 - '@oxfmt/binding-darwin-arm64': 0.34.0 - '@oxfmt/binding-darwin-x64': 0.34.0 - '@oxfmt/binding-freebsd-x64': 0.34.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.34.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.34.0 - '@oxfmt/binding-linux-arm64-gnu': 0.34.0 - '@oxfmt/binding-linux-arm64-musl': 0.34.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.34.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.34.0 - '@oxfmt/binding-linux-riscv64-musl': 0.34.0 - '@oxfmt/binding-linux-s390x-gnu': 0.34.0 - '@oxfmt/binding-linux-x64-gnu': 0.34.0 - '@oxfmt/binding-linux-x64-musl': 0.34.0 - '@oxfmt/binding-openharmony-arm64': 0.34.0 - '@oxfmt/binding-win32-arm64-msvc': 0.34.0 - '@oxfmt/binding-win32-ia32-msvc': 0.34.0 - '@oxfmt/binding-win32-x64-msvc': 0.34.0 - - oxlint-tsgolint@0.14.2: + '@oxfmt/binding-android-arm-eabi': 0.35.0 + '@oxfmt/binding-android-arm64': 0.35.0 + '@oxfmt/binding-darwin-arm64': 0.35.0 + '@oxfmt/binding-darwin-x64': 0.35.0 + '@oxfmt/binding-freebsd-x64': 0.35.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.35.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.35.0 + '@oxfmt/binding-linux-arm64-gnu': 0.35.0 + '@oxfmt/binding-linux-arm64-musl': 0.35.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.35.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.35.0 + '@oxfmt/binding-linux-riscv64-musl': 0.35.0 + '@oxfmt/binding-linux-s390x-gnu': 0.35.0 + '@oxfmt/binding-linux-x64-gnu': 0.35.0 + '@oxfmt/binding-linux-x64-musl': 0.35.0 + '@oxfmt/binding-openharmony-arm64': 0.35.0 + '@oxfmt/binding-win32-arm64-msvc': 0.35.0 + '@oxfmt/binding-win32-ia32-msvc': 0.35.0 + '@oxfmt/binding-win32-x64-msvc': 0.35.0 + + oxlint-tsgolint@0.15.0: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.14.2 - '@oxlint-tsgolint/darwin-x64': 0.14.2 - '@oxlint-tsgolint/linux-arm64': 0.14.2 - '@oxlint-tsgolint/linux-x64': 0.14.2 - '@oxlint-tsgolint/win32-arm64': 0.14.2 - '@oxlint-tsgolint/win32-x64': 0.14.2 - - oxlint@1.49.0(oxlint-tsgolint@0.14.2): + '@oxlint-tsgolint/darwin-arm64': 0.15.0 + '@oxlint-tsgolint/darwin-x64': 0.15.0 + '@oxlint-tsgolint/linux-arm64': 0.15.0 + '@oxlint-tsgolint/linux-x64': 0.15.0 + '@oxlint-tsgolint/win32-arm64': 0.15.0 + '@oxlint-tsgolint/win32-x64': 0.15.0 + + oxlint@1.50.0(oxlint-tsgolint@0.15.0): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.49.0 - '@oxlint/binding-android-arm64': 1.49.0 - '@oxlint/binding-darwin-arm64': 1.49.0 - '@oxlint/binding-darwin-x64': 1.49.0 - '@oxlint/binding-freebsd-x64': 1.49.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.49.0 - '@oxlint/binding-linux-arm-musleabihf': 1.49.0 - '@oxlint/binding-linux-arm64-gnu': 1.49.0 - '@oxlint/binding-linux-arm64-musl': 1.49.0 - '@oxlint/binding-linux-ppc64-gnu': 1.49.0 - '@oxlint/binding-linux-riscv64-gnu': 1.49.0 - '@oxlint/binding-linux-riscv64-musl': 1.49.0 - '@oxlint/binding-linux-s390x-gnu': 1.49.0 - '@oxlint/binding-linux-x64-gnu': 1.49.0 - '@oxlint/binding-linux-x64-musl': 1.49.0 - '@oxlint/binding-openharmony-arm64': 1.49.0 - '@oxlint/binding-win32-arm64-msvc': 1.49.0 - '@oxlint/binding-win32-ia32-msvc': 1.49.0 - '@oxlint/binding-win32-x64-msvc': 1.49.0 - oxlint-tsgolint: 0.14.2 + '@oxlint/binding-android-arm-eabi': 1.50.0 + '@oxlint/binding-android-arm64': 1.50.0 + '@oxlint/binding-darwin-arm64': 1.50.0 + '@oxlint/binding-darwin-x64': 1.50.0 + '@oxlint/binding-freebsd-x64': 1.50.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.50.0 + '@oxlint/binding-linux-arm-musleabihf': 1.50.0 + '@oxlint/binding-linux-arm64-gnu': 1.50.0 + '@oxlint/binding-linux-arm64-musl': 1.50.0 + '@oxlint/binding-linux-ppc64-gnu': 1.50.0 + '@oxlint/binding-linux-riscv64-gnu': 1.50.0 + '@oxlint/binding-linux-riscv64-musl': 1.50.0 + '@oxlint/binding-linux-s390x-gnu': 1.50.0 + '@oxlint/binding-linux-x64-gnu': 1.50.0 + '@oxlint/binding-linux-x64-musl': 1.50.0 + '@oxlint/binding-openharmony-arm64': 1.50.0 + '@oxlint/binding-win32-arm64-msvc': 1.50.0 + '@oxlint/binding-win32-ia32-msvc': 1.50.0 + '@oxlint/binding-win32-x64-msvc': 1.50.0 + oxlint-tsgolint: 0.15.0 p-finally@1.0.0: {} @@ -10851,6 +11944,11 @@ snapshots: '@discordjs/opus': 0.10.0 opusscript: 0.0.8 + prism-media@1.3.5(@discordjs/opus@0.10.0)(opusscript@0.1.1): + optionalDependencies: + '@discordjs/opus': 0.10.0 + opusscript: 0.1.1 + process-nextick-args@2.0.1: {} process-warning@5.0.0: {} @@ -11039,7 +12137,7 @@ snapshots: dependencies: glob: 10.5.0 - rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260222.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): + rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260224.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.1 '@babel/helper-validator-identifier': 8.0.0-rc.1 @@ -11052,7 +12150,7 @@ snapshots: obug: 2.1.1 rolldown: 1.0.0-rc.3 optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260222.1 + '@typescript/native-preview': 7.0.0-dev.20260224.1 typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver @@ -11501,7 +12599,7 @@ snapshots: ts-algebra@2.0.0: {} - tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260222.1)(typescript@5.9.3): + tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260224.1)(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -11512,7 +12610,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.3 rolldown: 1.0.0-rc.3 - rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260222.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3) + rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260224.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3) semver: 7.7.4 tinyexec: 1.0.2 tinyglobby: 0.2.15 From bd213cf2ad05a5443df96364c4ff0afa7c8a4bf0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:11:20 +0000 Subject: [PATCH 327/408] fix(agents): normalize SiliconFlow Pro thinking=off payload (#25435) Land PR #25435 from @Zjianru. Changelog: add 2026.2.24 fix entry with contributor credit. Co-authored-by: codez --- CHANGELOG.md | 1 + .../pi-embedded-runner-extraparams.test.ts | 62 +++++++++++++++++++ src/agents/pi-embedded-runner/extra-params.ts | 43 +++++++++++++ 3 files changed, 106 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53a58d8ae4ac..72be42da620f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - WhatsApp/Web reconnect: treat close status `440` as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson. - Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko. - Models/Bedrock auth: normalize additional Bedrock provider aliases (`bedrock`, `aws-bedrock`, `aws_bedrock`, `amazon bedrock`) to canonical `amazon-bedrock`, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13. +- Providers/SiliconFlow: normalize `thinking="off"` to `thinking: null` for `Pro/*` model payloads to avoid provider-side 400 loops and misleading compaction retries. (#25435) Thanks @Zjianru. - Gateway/Models: honor explicit `agents.defaults.models` allowlist refs even when bundled model catalog data is stale, synthesize missing allowlist entries in `models.list`, and allow `sessions.patch`/`/model` selection for those refs without false `model not allowed` errors. (#20291) Thanks @kensipe, @nikolasdehor, and @vincentkoc. - Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky. - Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire `messages.statusReactions.{emojis,timing}` into Discord reaction lifecycle control, and compact model-picker `custom_id` keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr. diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 1e47be3ee1f6..4392edfb3e18 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -310,6 +310,68 @@ describe("applyExtraParamsToAgent", () => { expect(payloads[0]).toEqual({ reasoning: { max_tokens: 256 } }); }); + it("normalizes thinking=off to null for SiliconFlow Pro models", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { thinking: "off" }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent( + agent, + undefined, + "siliconflow", + "Pro/MiniMaxAI/MiniMax-M2.1", + undefined, + "off", + ); + + const model = { + api: "openai-completions", + provider: "siliconflow", + id: "Pro/MiniMaxAI/MiniMax-M2.1", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.thinking).toBeNull(); + }); + + it("keeps thinking=off unchanged for non-Pro SiliconFlow model IDs", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { thinking: "off" }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent( + agent, + undefined, + "siliconflow", + "deepseek-ai/DeepSeek-V3.2", + undefined, + "off", + ); + + const model = { + api: "openai-completions", + provider: "siliconflow", + id: "deepseek-ai/DeepSeek-V3.2", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.thinking).toBe("off"); + }); + it("adds OpenRouter attribution headers to stream options", () => { const { calls, agent } = createOptionsCaptureAgent(); diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 0d88bdf08f33..05c764d15c7c 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -408,6 +408,42 @@ function mapThinkingLevelToOpenRouterReasoningEffort( return thinkingLevel; } +function shouldApplySiliconFlowThinkingOffCompat(params: { + provider: string; + modelId: string; + thinkingLevel?: ThinkLevel; +}): boolean { + return ( + params.provider === "siliconflow" && + params.thinkingLevel === "off" && + params.modelId.startsWith("Pro/") + ); +} + +/** + * SiliconFlow's Pro/* models reject string thinking modes (including "off") + * with HTTP 400 invalid-parameter errors. Normalize to `thinking: null` to + * preserve "thinking disabled" intent without sending an invalid enum value. + */ +function createSiliconFlowThinkingWrapper(baseStreamFn: StreamFn | undefined): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + const originalOnPayload = options?.onPayload; + return underlying(model, context, { + ...options, + onPayload: (payload) => { + if (payload && typeof payload === "object") { + const payloadObj = payload as Record; + if (payloadObj.thinking === "off") { + payloadObj.thinking = null; + } + } + originalOnPayload?.(payload); + }, + }); + }; +} + /** * Create a streamFn wrapper that adds OpenRouter app attribution headers * and injects reasoning.effort based on the configured thinking level. @@ -544,6 +580,13 @@ export function applyExtraParamsToAgent( agent.streamFn = createAnthropicBetaHeadersWrapper(agent.streamFn, anthropicBetas); } + if (shouldApplySiliconFlowThinkingOffCompat({ provider, modelId, thinkingLevel })) { + log.debug( + `normalizing thinking=off to thinking=null for SiliconFlow compatibility (${provider}/${modelId})`, + ); + agent.streamFn = createSiliconFlowThinkingWrapper(agent.streamFn); + } + if (provider === "openrouter") { log.debug(`applying OpenRouter app attribution headers for ${provider}/${modelId}`); // "auto" is a dynamic routing model — we don't know which underlying model From 0078070680a3c88d9d24d4656c3e41411c0d5932 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:16:03 +0000 Subject: [PATCH 328/408] fix(telegram): refresh global undici dispatcher for autoSelectFamily (#25682) Land PR #25682 from @lairtonlelis after maintainer rework: track dispatcher updates when network decision changes to avoid stale global fetch behavior. Co-authored-by: Ailton --- CHANGELOG.md | 1 + src/telegram/fetch.test.ts | 54 ++++++++++++++++++++++++++++++++++++++ src/telegram/fetch.ts | 30 +++++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72be42da620f..bc3793181fa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. Thanks @tdjackey for reporting. - Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. Thanks @tdjackey for reporting. - Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. +- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis. - Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. - Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. - Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. diff --git a/src/telegram/fetch.test.ts b/src/telegram/fetch.test.ts index 9f1c676119be..b36f5dab7a83 100644 --- a/src/telegram/fetch.test.ts +++ b/src/telegram/fetch.test.ts @@ -4,6 +4,12 @@ import { resetTelegramFetchStateForTests, resolveTelegramFetch } from "./fetch.j const setDefaultAutoSelectFamily = vi.hoisted(() => vi.fn()); const setDefaultResultOrder = vi.hoisted(() => vi.fn()); +const setGlobalDispatcher = vi.hoisted(() => vi.fn()); +const AgentCtor = vi.hoisted(() => + vi.fn(function MockAgent(this: { options: unknown }, options: unknown) { + this.options = options; + }), +); vi.mock("node:net", async () => { const actual = await vi.importActual("node:net"); @@ -21,12 +27,19 @@ vi.mock("node:dns", async () => { }; }); +vi.mock("undici", () => ({ + Agent: AgentCtor, + setGlobalDispatcher, +})); + const originalFetch = globalThis.fetch; afterEach(() => { resetTelegramFetchStateForTests(); setDefaultAutoSelectFamily.mockReset(); setDefaultResultOrder.mockReset(); + setGlobalDispatcher.mockReset(); + AgentCtor.mockClear(); vi.unstubAllEnvs(); vi.clearAllMocks(); if (originalFetch) { @@ -133,4 +146,45 @@ describe("resolveTelegramFetch", () => { expect(setDefaultResultOrder).toHaveBeenCalledTimes(2); }); + + it("replaces global undici dispatcher with autoSelectFamily-enabled agent", async () => { + globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; + resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); + + expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); + expect(AgentCtor).toHaveBeenCalledWith({ + connect: { + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }, + }); + }); + + it("sets global dispatcher only once across repeated equal decisions", async () => { + globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; + resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); + resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); + + expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); + }); + + it("updates global dispatcher when autoSelectFamily decision changes", async () => { + globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; + resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); + resolveTelegramFetch(undefined, { network: { autoSelectFamily: false } }); + + expect(setGlobalDispatcher).toHaveBeenCalledTimes(2); + expect(AgentCtor).toHaveBeenNthCalledWith(1, { + connect: { + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }, + }); + expect(AgentCtor).toHaveBeenNthCalledWith(2, { + connect: { + autoSelectFamily: false, + autoSelectFamilyAttemptTimeout: 300, + }, + }); + }); }); diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts index 48fdf72eff79..3dec18cc0ddb 100644 --- a/src/telegram/fetch.ts +++ b/src/telegram/fetch.ts @@ -1,5 +1,6 @@ import * as dns from "node:dns"; import * as net from "node:net"; +import { Agent, setGlobalDispatcher } from "undici"; import type { TelegramNetworkConfig } from "../config/types.telegram.js"; import { resolveFetch } from "../infra/fetch.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -10,6 +11,7 @@ import { let appliedAutoSelectFamily: boolean | null = null; let appliedDnsResultOrder: string | null = null; +let appliedGlobalDispatcherAutoSelectFamily: boolean | null = null; const log = createSubsystemLogger("telegram/network"); // Node 22 workaround: enable autoSelectFamily to allow IPv4 fallback on broken IPv6 networks. @@ -31,6 +33,33 @@ function applyTelegramNetworkWorkarounds(network?: TelegramNetworkConfig): void } } + // Node 22's built-in globalThis.fetch uses undici's internal Agent whose + // connect options are frozen at construction time. Calling + // net.setDefaultAutoSelectFamily() after that agent is created has no + // effect on it. Replace the global dispatcher with one that carries the + // current autoSelectFamily setting so subsequent globalThis.fetch calls + // inherit the same decision. + // See: https://github.com/openclaw/openclaw/issues/25676 + if ( + autoSelectDecision.value !== null && + autoSelectDecision.value !== appliedGlobalDispatcherAutoSelectFamily + ) { + try { + setGlobalDispatcher( + new Agent({ + connect: { + autoSelectFamily: autoSelectDecision.value, + autoSelectFamilyAttemptTimeout: 300, + }, + }), + ); + appliedGlobalDispatcherAutoSelectFamily = autoSelectDecision.value; + log.info(`global undici dispatcher autoSelectFamily=${autoSelectDecision.value}`); + } catch { + // ignore if setGlobalDispatcher is unavailable + } + } + // Apply DNS result order workaround for IPv4/IPv6 issues. // Some APIs (including Telegram) may fail with IPv6 on certain networks. // See: https://github.com/openclaw/openclaw/issues/5311 @@ -68,4 +97,5 @@ export function resolveTelegramFetch( export function resetTelegramFetchStateForTests(): void { appliedAutoSelectFamily = null; appliedDnsResultOrder = null; + appliedGlobalDispatcherAutoSelectFamily = null; } From 7dfac701858ce0efdb373eb90c729de8d4b46676 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:19:43 +0000 Subject: [PATCH 329/408] fix(synology-chat): land @bmendonca3 fail-closed allowlist follow-up (#25827) Carry fail-closed empty-allowlist guard clarity and changelog attribution for PR #25827. Co-authored-by: Brian Mendonca --- CHANGELOG.md | 2 +- extensions/synology-chat/src/security.test.ts | 2 +- extensions/synology-chat/src/security.ts | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc3793181fa9..66790a1a45c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,7 @@ Docs: https://docs.openclaw.ai - Zalo/Group policy: enforce sender authorization for group messages with `groupPolicy` + `groupAllowFrom` (fallback to `allowFrom`), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. Thanks @tdjackey for reporting. - Security/Telegram: enforce DM authorization before media download/write (including media groups) and move telegram inbound activity tracking after DM authorization, preventing unauthorized sender-triggered inbound media disk writes. Thanks @v8hid for reporting. - Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. Thanks @tdjackey for reporting. -- Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so `dmPolicy: "allowlist"` with empty `allowedUserIds` rejects all senders instead of allowing unauthorized dispatch. Thanks @tdjackey for reporting. +- Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so `dmPolicy: "allowlist"` with empty `allowedUserIds` rejects all senders instead of allowing unauthorized dispatch. (#25827) Thanks @bmendonca3 for the contribution and @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. Thanks @tdjackey for reporting. - Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting. diff --git a/extensions/synology-chat/src/security.test.ts b/extensions/synology-chat/src/security.test.ts index c6f697810af0..f77fd21ca8e0 100644 --- a/extensions/synology-chat/src/security.test.ts +++ b/extensions/synology-chat/src/security.test.ts @@ -30,7 +30,7 @@ describe("validateToken", () => { }); describe("checkUserAllowed", () => { - it("rejects user when allowlist is empty", () => { + it("rejects all users when allowlist is empty", () => { expect(checkUserAllowed("user1", [])).toBe(false); }); diff --git a/extensions/synology-chat/src/security.ts b/extensions/synology-chat/src/security.ts index 2e1b14312732..22883babbf5a 100644 --- a/extensions/synology-chat/src/security.ts +++ b/extensions/synology-chat/src/security.ts @@ -29,6 +29,7 @@ export function validateToken(received: string, expected: string): boolean { * Allowlist mode must be explicit; empty lists should not match any user. */ export function checkUserAllowed(userId: string, allowedUserIds: string[]): boolean { + if (allowedUserIds.length === 0) return false; return allowedUserIds.includes(userId); } From 43f318cd9af8ae31bebf31e79facecf70d062f96 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:21:37 +0000 Subject: [PATCH 330/408] fix(agents): reduce billing false positives on long text (#25680) Land PR #25680 from @lairtonlelis. Retain explicit status/code/http 402 detection for oversized structured payloads. Co-authored-by: Ailton --- CHANGELOG.md | 1 + ...dded-helpers.isbillingerrormessage.test.ts | 34 +++++++++++++++++++ src/agents/pi-embedded-helpers/errors.ts | 17 ++++++++++ 3 files changed, 52 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66790a1a45c3..61a679d293bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai - Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. Thanks @tdjackey for reporting. - Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. - Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis. +- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis. - Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. - Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. - Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 278c2d30bcb1..97b96727562a 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -69,6 +69,40 @@ describe("isBillingErrorMessage", () => { expect(isBillingErrorMessage(sample)).toBe(false); } }); + it("does not false-positive on long assistant responses mentioning billing keywords", () => { + // Simulate a multi-paragraph assistant response that mentions billing terms + const longResponse = + "Sure! Here's how to set up billing for your SaaS application.\n\n" + + "## Payment Integration\n\n" + + "First, you'll need to configure your payment gateway. Most providers offer " + + "a dashboard where you can manage credits, view invoices, and upgrade your plan. " + + "The billing page typically shows your current balance and payment history.\n\n" + + "## Managing Credits\n\n" + + "Users can purchase credits through the billing portal. When their credit balance " + + "runs low, send them a notification to upgrade their plan or add more credits. " + + "You should also handle insufficient balance cases gracefully.\n\n" + + "## Subscription Plans\n\n" + + "Offer multiple plan tiers with different features. Allow users to upgrade or " + + "downgrade their plan at any time. Make sure the billing cycle is clear.\n\n" + + "Let me know if you need more details on any of these topics!"; + expect(longResponse.length).toBeGreaterThan(512); + expect(isBillingErrorMessage(longResponse)).toBe(false); + }); + it("still matches explicit 402 markers in long payloads", () => { + const longStructuredError = + '{"error":{"code":402,"message":"payment required","details":"' + "x".repeat(700) + '"}}'; + expect(longStructuredError.length).toBeGreaterThan(512); + expect(isBillingErrorMessage(longStructuredError)).toBe(true); + }); + it("does not match long numeric text that is not a billing error", () => { + const longNonError = + "Quarterly report summary: subsystem A returned 402 records after retry. " + + "This is an analytics count, not an HTTP/API billing failure. " + + "Notes: " + + "x".repeat(700); + expect(longNonError.length).toBeGreaterThan(512); + expect(isBillingErrorMessage(longNonError)).toBe(false); + }); it("still matches real HTTP 402 billing errors", () => { const realErrors = [ "HTTP 402 Payment Required", diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index e0c7bf4c8017..29fcfea2c7d2 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -161,6 +161,8 @@ const CONTEXT_OVERFLOW_ERROR_HEAD_RE = /^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i; const BILLING_ERROR_HEAD_RE = /^(?:error[:\s-]+)?billing(?:\s+error)?(?:[:\s-]+|$)|^(?:error[:\s-]+)?(?:credit balance|insufficient credits?|payment required|http\s*402\b)/i; +const BILLING_ERROR_HARD_402_RE = + /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|^\s*402\s+payment/i; const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i; const HTTP_STATUS_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i; const HTML_ERROR_PREFIX_RE = /^\s*(?: BILLING_ERROR_MAX_LENGTH) { + // Keep explicit status/code 402 detection for providers that wrap errors in + // larger payloads (for example nested JSON bodies or prefixed metadata). + return BILLING_ERROR_HARD_402_RE.test(value); + } if (matchesErrorPatterns(value, ERROR_PATTERNS.billing)) { return true; } From 4d89548e59c5649641cc53bd27dda9b72ece6123 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:28:17 +0000 Subject: [PATCH 331/408] fix(ui): inherit default model fallbacks in agents overview (#25729) Land PR #25729 from @Suko. Use shared fallback-resolution helper and add regression coverage for default, override, and explicit-empty cases. Co-authored-by: suko --- CHANGELOG.md | 1 + ui/src/ui/views/agents-utils.test.ts | 42 ++++++++++++++++++++++++++++ ui/src/ui/views/agents-utils.ts | 7 +++++ ui/src/ui/views/agents.ts | 7 +++-- vitest.config.ts | 1 + 5 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 ui/src/ui/views/agents-utils.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 61a679d293bf..05d20545579c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Models/Bedrock auth: normalize additional Bedrock provider aliases (`bedrock`, `aws-bedrock`, `aws_bedrock`, `amazon bedrock`) to canonical `amazon-bedrock`, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13. - Providers/SiliconFlow: normalize `thinking="off"` to `thinking: null` for `Pro/*` model payloads to avoid provider-side 400 loops and misleading compaction retries. (#25435) Thanks @Zjianru. - Gateway/Models: honor explicit `agents.defaults.models` allowlist refs even when bundled model catalog data is stale, synthesize missing allowlist entries in `models.list`, and allow `sessions.patch`/`/model` selection for those refs without false `model not allowed` errors. (#20291) Thanks @kensipe, @nikolasdehor, and @vincentkoc. +- Control UI/Agents: inherit `agents.defaults.model.fallbacks` in the Overview fallback input when no per-agent model entry exists, while preserving explicit per-agent fallback overrides (including empty lists). (#25729, #25710) Thanks @Suko. - Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky. - Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire `messages.statusReactions.{emojis,timing}` into Discord reaction lifecycle control, and compact model-picker `custom_id` keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr. - Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin. diff --git a/ui/src/ui/views/agents-utils.test.ts b/ui/src/ui/views/agents-utils.test.ts new file mode 100644 index 000000000000..f63fbcab5b82 --- /dev/null +++ b/ui/src/ui/views/agents-utils.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { resolveEffectiveModelFallbacks } from "./agents-utils.ts"; + +describe("resolveEffectiveModelFallbacks", () => { + it("inherits defaults when no entry fallbacks are configured", () => { + const entryModel = undefined; + const defaultModel = { + primary: "openai/gpt-5-nano", + fallbacks: ["google/gemini-2.0-flash"], + }; + + expect(resolveEffectiveModelFallbacks(entryModel, defaultModel)).toEqual([ + "google/gemini-2.0-flash", + ]); + }); + + it("prefers entry fallbacks over defaults", () => { + const entryModel = { + primary: "openai/gpt-5-mini", + fallbacks: ["openai/gpt-5-nano"], + }; + const defaultModel = { + primary: "openai/gpt-5", + fallbacks: ["google/gemini-2.0-flash"], + }; + + expect(resolveEffectiveModelFallbacks(entryModel, defaultModel)).toEqual(["openai/gpt-5-nano"]); + }); + + it("keeps explicit empty entry fallback lists", () => { + const entryModel = { + primary: "openai/gpt-5-mini", + fallbacks: [], + }; + const defaultModel = { + primary: "openai/gpt-5", + fallbacks: ["google/gemini-2.0-flash"], + }; + + expect(resolveEffectiveModelFallbacks(entryModel, defaultModel)).toEqual([]); + }); +}); diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index c09e4a58ad30..3b72f5e36fb2 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -244,6 +244,13 @@ export function resolveModelFallbacks(model?: unknown): string[] | null { return null; } +export function resolveEffectiveModelFallbacks( + entryModel?: unknown, + defaultModel?: unknown, +): string[] | null { + return resolveModelFallbacks(entryModel) ?? resolveModelFallbacks(defaultModel); +} + export function parseFallbackList(value: string): string[] { return value .split(",") diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index 72a0b88a92cf..891190d9abb2 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/ui/views/agents.ts @@ -24,7 +24,7 @@ import { parseFallbackList, resolveAgentConfig, resolveAgentEmoji, - resolveModelFallbacks, + resolveEffectiveModelFallbacks, resolveModelLabel, resolveModelPrimary, } from "./agents-utils.ts"; @@ -390,7 +390,10 @@ function renderAgentOverview(params: { resolveModelPrimary(config.defaults?.model) || (defaultModel !== "-" ? normalizeModelValue(defaultModel) : null); const effectivePrimary = modelPrimary ?? defaultPrimary ?? null; - const modelFallbacks = resolveModelFallbacks(config.entry?.model); + const modelFallbacks = resolveEffectiveModelFallbacks( + config.entry?.model, + config.defaults?.model, + ); const fallbackText = modelFallbacks ? modelFallbacks.join(", ") : ""; const identityName = agentIdentity?.name?.trim() || diff --git a/vitest.config.ts b/vitest.config.ts index 522e3d2b3145..8b1588489307 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -37,6 +37,7 @@ export default defineConfig({ "src/**/*.test.ts", "extensions/**/*.test.ts", "test/**/*.test.ts", + "ui/src/ui/views/agents-utils.test.ts", "ui/src/ui/views/usage-render-details.test.ts", "ui/src/ui/controllers/agents.test.ts", ], From e2362d352d14617a7f725d8b2254ed5fe4c450aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:33:32 +0000 Subject: [PATCH 332/408] fix(heartbeat): default target none and internalize relay prompts --- CHANGELOG.md | 1 + docs/automation/cron-vs-heartbeat.md | 2 +- docs/gateway/configuration-reference.md | 2 +- docs/gateway/heartbeat.md | 14 +- src/infra/heartbeat-events-filter.test.ts | 21 +++ src/infra/heartbeat-events-filter.ts | 36 +++++- ...tbeat-runner.returns-default-unset.test.ts | 121 +++++++++++++++++- src/infra/heartbeat-runner.ts | 22 ++-- src/infra/outbound/targets.ts | 2 +- 9 files changed, 191 insertions(+), 30 deletions(-) create mode 100644 src/infra/heartbeat-events-filter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d20545579c..efb19ccd6bf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai - Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. - Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch. - Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. +- Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from `last` to `none` (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851) - Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting. - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. - Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. Thanks @GCXWLP for reporting. diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md index c25cbcb80dbc..9676d960d236 100644 --- a/docs/automation/cron-vs-heartbeat.md +++ b/docs/automation/cron-vs-heartbeat.md @@ -62,7 +62,7 @@ The agent reads this on each heartbeat and handles all items in one turn. defaults: { heartbeat: { every: "30m", // interval - target: "last", // where to deliver alerts + target: "last", // explicit alert delivery target (default is "none") activeHours: { start: "08:00", end: "22:00" }, // optional }, }, diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 432e472f21dd..58c1d6fd504a 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -800,7 +800,7 @@ Periodic heartbeat runs. includeReasoning: false, session: "main", to: "+15555550123", - target: "last", // last | whatsapp | telegram | discord | ... | none + target: "none", // default: none | options: last | whatsapp | telegram | discord | ... prompt: "Read HEARTBEAT.md if it exists...", ackMaxChars: 300, suppressToolErrorWarnings: false, diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index b682da0f814d..e22d09906127 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -19,7 +19,7 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting) 1. Leave heartbeats enabled (default is `30m`, or `1h` for Anthropic OAuth/setup-token) or set your own cadence. 2. Create a tiny `HEARTBEAT.md` checklist in the agent workspace (optional but recommended). -3. Decide where heartbeat messages should go (`target: "last"` is the default). +3. Decide where heartbeat messages should go (`target: "none"` is the default; set `target: "last"` to route to the last contact). 4. Optional: enable heartbeat reasoning delivery for transparency. 5. Optional: restrict heartbeats to active hours (local time). @@ -31,7 +31,7 @@ Example config: defaults: { heartbeat: { every: "30m", - target: "last", + target: "last", // explicit delivery to last contact (default is "none") // activeHours: { start: "08:00", end: "24:00" }, // includeReasoning: true, // optional: send separate `Reasoning:` message too }, @@ -87,7 +87,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped. every: "30m", // default: 30m (0m disables) model: "anthropic/claude-opus-4-6", includeReasoning: false, // default: false (deliver separate Reasoning: message when available) - target: "last", // last | none | (core or plugin, e.g. "bluebubbles") + target: "last", // default: none | options: last | none | (core or plugin, e.g. "bluebubbles") to: "+15551234567", // optional channel-specific override accountId: "ops-bot", // optional multi-account channel id prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.", @@ -120,7 +120,7 @@ Example: two agents, only the second agent runs heartbeats. defaults: { heartbeat: { every: "30m", - target: "last", + target: "last", // explicit delivery to last contact (default is "none") }, }, list: [ @@ -149,7 +149,7 @@ Restrict heartbeats to business hours in a specific timezone: defaults: { heartbeat: { every: "30m", - target: "last", + target: "last", // explicit delivery to last contact (default is "none") activeHours: { start: "09:00", end: "22:00", @@ -212,9 +212,9 @@ Use `accountId` to target a specific account on multi-account channels like Tele - Explicit session key (copy from `openclaw sessions --json` or the [sessions CLI](/cli/sessions)). - Session key formats: see [Sessions](/concepts/session) and [Groups](/channels/groups). - `target`: - - `last` (default): deliver to the last used external channel. + - `last`: deliver to the last used external channel. - explicit channel: `whatsapp` / `telegram` / `discord` / `googlechat` / `slack` / `msteams` / `signal` / `imessage`. - - `none`: run the heartbeat but **do not deliver** externally. + - `none` (default): run the heartbeat but **do not deliver** externally. - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). For Telegram topics/threads, use `:topic:`. - `accountId`: optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped. - `prompt`: overrides the default prompt body (not merged). diff --git a/src/infra/heartbeat-events-filter.test.ts b/src/infra/heartbeat-events-filter.test.ts new file mode 100644 index 000000000000..dab2250dd0ec --- /dev/null +++ b/src/infra/heartbeat-events-filter.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { buildCronEventPrompt, buildExecEventPrompt } from "./heartbeat-events-filter.js"; + +describe("heartbeat event prompts", () => { + it("builds user-relay cron prompt by default", () => { + const prompt = buildCronEventPrompt(["Cron: rotate logs"]); + expect(prompt).toContain("Please relay this reminder to the user"); + }); + + it("builds internal-only cron prompt when delivery is disabled", () => { + const prompt = buildCronEventPrompt(["Cron: rotate logs"], { deliverToUser: false }); + expect(prompt).toContain("Handle this reminder internally"); + expect(prompt).not.toContain("Please relay this reminder to the user"); + }); + + it("builds internal-only exec prompt when delivery is disabled", () => { + const prompt = buildExecEventPrompt({ deliverToUser: false }); + expect(prompt).toContain("Handle the result internally"); + expect(prompt).not.toContain("Please relay the command output to the user"); + }); +}); diff --git a/src/infra/heartbeat-events-filter.ts b/src/infra/heartbeat-events-filter.ts index f5042bb0bdfd..1682c3b308ba 100644 --- a/src/infra/heartbeat-events-filter.ts +++ b/src/infra/heartbeat-events-filter.ts @@ -3,14 +3,33 @@ import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js"; // Build a dynamic prompt for cron events by embedding the actual event content. // This ensures the model sees the reminder text directly instead of relying on // "shown in the system messages above" which may not be visible in context. -export function buildCronEventPrompt(pendingEvents: string[]): string { +export function buildCronEventPrompt( + pendingEvents: string[], + opts?: { + deliverToUser?: boolean; + }, +): string { + const deliverToUser = opts?.deliverToUser ?? true; const eventText = pendingEvents.join("\n").trim(); if (!eventText) { + if (!deliverToUser) { + return ( + "A scheduled cron event was triggered, but no event content was found. " + + "Handle this internally and reply HEARTBEAT_OK when nothing needs user-facing follow-up." + ); + } return ( "A scheduled cron event was triggered, but no event content was found. " + "Reply HEARTBEAT_OK." ); } + if (!deliverToUser) { + return ( + "A scheduled reminder has been triggered. The reminder content is:\n\n" + + eventText + + "\n\nHandle this reminder internally. Do not relay it to the user unless explicitly requested." + ); + } return ( "A scheduled reminder has been triggered. The reminder content is:\n\n" + eventText + @@ -18,6 +37,21 @@ export function buildCronEventPrompt(pendingEvents: string[]): string { ); } +export function buildExecEventPrompt(opts?: { deliverToUser?: boolean }): string { + const deliverToUser = opts?.deliverToUser ?? true; + if (!deliverToUser) { + return ( + "An async command you ran earlier has completed. The result is shown in the system messages above. " + + "Handle the result internally. Do not relay it to the user unless explicitly requested." + ); + } + return ( + "An async command you ran earlier has completed. The result is shown in the system messages above. " + + "Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " + + "If it failed, explain what went wrong." + ); +} + const HEARTBEAT_OK_PREFIX = HEARTBEAT_TOKEN.toLowerCase(); // Detect heartbeat-specific noise so cron reminders don't trigger on non-reminder events. diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 908f45ebb522..2f1748bae1b5 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -239,12 +239,12 @@ describe("resolveHeartbeatDeliveryTarget", () => { }, }, { - name: "use last route by default", + name: "target defaults to none when unset", cfg: {}, entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "+1555" }, expected: { - channel: "whatsapp", - to: "+1555", + channel: "none", + reason: "target-none", accountId: undefined, lastChannel: "whatsapp", lastAccountId: undefined, @@ -271,7 +271,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { entry: { ...baseEntry, lastChannel: "webchat", lastTo: "web" }, expected: { channel: "none", - reason: "no-target", + reason: "target-none", accountId: undefined, lastChannel: undefined, lastAccountId: undefined, @@ -294,7 +294,10 @@ describe("resolveHeartbeatDeliveryTarget", () => { }, { name: "normalize prefixed whatsapp group targets", - cfg: { channels: { whatsapp: { allowFrom: ["+1555"] } } }, + cfg: { + agents: { defaults: { heartbeat: { target: "last" } } }, + channels: { whatsapp: { allowFrom: ["+1555"] } }, + }, entry: { ...baseEntry, lastChannel: "whatsapp", @@ -927,7 +930,7 @@ describe("runHeartbeatOnce", () => { try { const cfg: OpenClawConfig = { agents: { - defaults: { workspace: tmpDir, heartbeat: { every: "5m" } }, + defaults: { workspace: tmpDir, heartbeat: { every: "5m", target: "whatsapp" } }, list: [{ id: "work", default: true }], }, channels: { whatsapp: { allowFrom: ["*"] } }, @@ -1148,4 +1151,110 @@ describe("runHeartbeatOnce", () => { } } }); + + it("uses an internal-only cron prompt when heartbeat delivery target is none", async () => { + const tmpDir = await createCaseDir("hb-cron-target-none"); + const storePath = path.join(tmpDir, "sessions.json"); + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: tmpDir, + heartbeat: { every: "5m", target: "none" }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const sessionKey = resolveMainSessionKey(cfg); + await fs.writeFile( + storePath, + JSON.stringify({ + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", + }, + }), + ); + enqueueSystemEvent("Cron: rotate logs", { + sessionKey, + contextKey: "cron:rotate-logs", + }); + + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + replySpy.mockResolvedValue({ text: "Handled internally" }); + const sendWhatsApp = vi + .fn>() + .mockResolvedValue({ messageId: "m1", toJid: "jid" }); + + try { + const res = await runHeartbeatOnce({ + cfg, + reason: "interval", + deps: createHeartbeatDeps(sendWhatsApp), + }); + expect(res.status).toBe("ran"); + expect(sendWhatsApp).toHaveBeenCalledTimes(0); + const calledCtx = replySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string }; + expect(calledCtx.Provider).toBe("cron-event"); + expect(calledCtx.Body).toContain("Handle this reminder internally"); + expect(calledCtx.Body).not.toContain("Please relay this reminder to the user"); + } finally { + replySpy.mockRestore(); + } + }); + + it("uses an internal-only exec prompt when heartbeat delivery target is none", async () => { + const tmpDir = await createCaseDir("hb-exec-target-none"); + const storePath = path.join(tmpDir, "sessions.json"); + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: tmpDir, + heartbeat: { every: "5m", target: "none" }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const sessionKey = resolveMainSessionKey(cfg); + await fs.writeFile( + storePath, + JSON.stringify({ + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", + }, + }), + ); + enqueueSystemEvent("exec finished: backup completed", { + sessionKey, + contextKey: "exec:backup", + }); + + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + replySpy.mockResolvedValue({ text: "Handled internally" }); + const sendWhatsApp = vi + .fn>() + .mockResolvedValue({ messageId: "m1", toJid: "jid" }); + + try { + const res = await runHeartbeatOnce({ + cfg, + reason: "exec-event", + deps: createHeartbeatDeps(sendWhatsApp), + }); + expect(res.status).toBe("ran"); + expect(sendWhatsApp).toHaveBeenCalledTimes(0); + const calledCtx = replySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string }; + expect(calledCtx.Provider).toBe("exec-event"); + expect(calledCtx.Body).toContain("Handle the result internally"); + expect(calledCtx.Body).not.toContain("Please relay the command output to the user"); + } finally { + replySpy.mockRestore(); + } + }); }); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index ad2c091f1562..b7ae733e6336 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -44,6 +44,7 @@ import { escapeRegExp } from "../utils.js"; import { formatErrorMessage, hasErrnoCode } from "./errors.js"; import { isWithinActiveHours } from "./heartbeat-active-hours.js"; import { + buildExecEventPrompt, buildCronEventPrompt, isCronSystemEvent, isExecCompletionEvent, @@ -95,15 +96,7 @@ export type HeartbeatSummary = { ackMaxChars: number; }; -const DEFAULT_HEARTBEAT_TARGET = "last"; - -// Prompt used when an async exec has completed and the result should be relayed to the user. -// This overrides the standard heartbeat prompt to ensure the model responds with the exec result -// instead of just "HEARTBEAT_OK". -const EXEC_EVENT_PROMPT = - "An async command you ran earlier has completed. The result is shown in the system messages above. " + - "Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " + - "If it failed, explain what went wrong."; +const DEFAULT_HEARTBEAT_TARGET = "none"; export { isCronSystemEvent }; type HeartbeatAgentState = { @@ -615,12 +608,12 @@ export async function runHeartbeatOnce(opts: { if (delivery.reason === "unknown-account") { log.warn("heartbeat: unknown accountId", { accountId: delivery.accountId ?? heartbeatAccountId ?? null, - target: heartbeat?.target ?? "last", + target: heartbeat?.target ?? "none", }); } else if (heartbeatAccountId) { log.info("heartbeat: using explicit accountId", { accountId: delivery.accountId ?? heartbeatAccountId, - target: heartbeat?.target ?? "last", + target: heartbeat?.target ?? "none", channel: delivery.channel, }); } @@ -654,10 +647,13 @@ export async function runHeartbeatOnce(opts: { .map((event) => event.text); const hasExecCompletion = pendingEvents.some(isExecCompletionEvent); const hasCronEvents = cronEvents.length > 0; + const canRelayToUser = Boolean( + delivery.channel !== "none" && delivery.to && visibility.showAlerts, + ); const prompt = hasExecCompletion - ? EXEC_EVENT_PROMPT + ? buildExecEventPrompt({ deliverToUser: canRelayToUser }) : hasCronEvents - ? buildCronEventPrompt(cronEvents) + ? buildCronEventPrompt(cronEvents, { deliverToUser: canRelayToUser }) : resolveHeartbeatPrompt(cfg, heartbeat); const ctx = { Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt), diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 8a33353bb5a9..f03918423e2b 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -210,7 +210,7 @@ export function resolveHeartbeatDeliveryTarget(params: { const { cfg, entry } = params; const heartbeat = params.heartbeat ?? cfg.agents?.defaults?.heartbeat; const rawTarget = heartbeat?.target; - let target: HeartbeatTarget = "last"; + let target: HeartbeatTarget = "none"; if (rawTarget === "none" || rawTarget === "last") { target = rawTarget; } else if (typeof rawTarget === "string") { From a177b10b79bcfeefed1a9970294fadf56f31ad1e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:11:47 +0000 Subject: [PATCH 333/408] test(windows): normalize risky-path assertions --- src/agents/sandbox-paths.test.ts | 2 +- src/security/audit.test.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/agents/sandbox-paths.test.ts b/src/agents/sandbox-paths.test.ts index 6111980e1384..fd6998994b25 100644 --- a/src/agents/sandbox-paths.test.ts +++ b/src/agents/sandbox-paths.test.ts @@ -50,7 +50,7 @@ describe("resolveSandboxedMediaSource", () => { media, sandboxRoot: sandboxDir, }); - expect(result).toBe(expected); + expect(result).toBe(path.resolve(expected)); }); }); diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 04c459070414..93e9d1131741 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -439,10 +439,14 @@ describe("security audit", () => { }); it("warns for risky safeBinTrustedDirs entries", async () => { + const riskyGlobalTrustedDirs = + process.platform === "win32" + ? [String.raw`C:\Users\ci-user\bin`, String.raw`C:\Users\ci-user\.local\bin`] + : ["/usr/local/bin", "/tmp/openclaw-safe-bins"]; const cfg: OpenClawConfig = { tools: { exec: { - safeBinTrustedDirs: ["/usr/local/bin", "/tmp/openclaw-safe-bins"], + safeBinTrustedDirs: riskyGlobalTrustedDirs, }, }, agents: { @@ -464,8 +468,8 @@ describe("security audit", () => { (f) => f.checkId === "tools.exec.safe_bin_trusted_dirs_risky", ); expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain("/usr/local/bin"); - expect(finding?.detail).toContain("/tmp/openclaw-safe-bins"); + expect(finding?.detail).toContain(riskyGlobalTrustedDirs[0]); + expect(finding?.detail).toContain(riskyGlobalTrustedDirs[1]); expect(finding?.detail).toContain("agents.list.ops.tools.exec"); }); From 039713c3e7969ced6ccd0b12fee05294297af32f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:36:19 +0000 Subject: [PATCH 334/408] fix: suppress reasoning payload leakage in whatsapp replies --- CHANGELOG.md | 1 + src/web/auto-reply/deliver-reply.test.ts | 50 ++++++++++++++++++++++++ src/web/auto-reply/deliver-reply.ts | 17 ++++++++ 3 files changed, 68 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index efb19ccd6bf4..32de2c31f6ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3. - Providers/OpenRouter/Auth profiles: bypass auth-profile cooldown/disable windows for OpenRouter, so provider failures no longer put OpenRouter profiles into local cooldown and stale legacy cooldown markers are ignored in fallback and status selection paths. (#25892) Thanks @alexanderatallah for raising this and @vincentkoc for the fix. - WhatsApp/Web reconnect: treat close status `440` as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson. +- WhatsApp/Reasoning safety: suppress outbound payloads marked as reasoning and hard-drop text payloads that begin with `Reasoning:` before WhatsApp delivery, preventing hidden thinking blocks from leaking to end users through final-message paths. (#25804, #25214, #24328) - Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko. - Models/Bedrock auth: normalize additional Bedrock provider aliases (`bedrock`, `aws-bedrock`, `aws_bedrock`, `amazon bedrock`) to canonical `amazon-bedrock`, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13. - Providers/SiliconFlow: normalize `thinking="off"` to `thinking: null` for `Pro/*` model payloads to avoid provider-side 400 loops and misleading compaction retries. (#25435) Thanks @Zjianru. diff --git a/src/web/auto-reply/deliver-reply.test.ts b/src/web/auto-reply/deliver-reply.test.ts index 24f6e2eb82ed..e3dfe6126bbd 100644 --- a/src/web/auto-reply/deliver-reply.test.ts +++ b/src/web/auto-reply/deliver-reply.test.ts @@ -70,6 +70,56 @@ const replyLogger = { }; describe("deliverWebReply", () => { + it("suppresses payloads flagged as reasoning", async () => { + const msg = makeMsg(); + + await deliverWebReply({ + replyResult: { text: "Reasoning:\n_hidden_", isReasoning: true }, + msg, + maxMediaBytes: 1024 * 1024, + textLimit: 200, + replyLogger, + skipLog: true, + }); + + expect(msg.reply).not.toHaveBeenCalled(); + expect(msg.sendMedia).not.toHaveBeenCalled(); + }); + + it("suppresses payloads that start with reasoning prefix text", async () => { + const msg = makeMsg(); + + await deliverWebReply({ + replyResult: { text: " \n Reasoning:\n_hidden_" }, + msg, + maxMediaBytes: 1024 * 1024, + textLimit: 200, + replyLogger, + skipLog: true, + }); + + expect(msg.reply).not.toHaveBeenCalled(); + expect(msg.sendMedia).not.toHaveBeenCalled(); + }); + + it("does not suppress messages that mention Reasoning: mid-text", async () => { + const msg = makeMsg(); + + await deliverWebReply({ + replyResult: { text: "Intro line\nReasoning: appears in content but is not a prefix" }, + msg, + maxMediaBytes: 1024 * 1024, + textLimit: 200, + replyLogger, + skipLog: true, + }); + + expect(msg.reply).toHaveBeenCalledTimes(1); + expect(msg.reply).toHaveBeenCalledWith( + "Intro line\nReasoning: appears in content but is not a prefix", + ); + }); + it("sends chunked text replies and logs a summary", async () => { const msg = makeMsg(); diff --git a/src/web/auto-reply/deliver-reply.ts b/src/web/auto-reply/deliver-reply.ts index 664e8acee852..7866fea0c8a0 100644 --- a/src/web/auto-reply/deliver-reply.ts +++ b/src/web/auto-reply/deliver-reply.ts @@ -12,6 +12,19 @@ import { whatsappOutboundLog } from "./loggers.js"; import type { WebInboundMsg } from "./types.js"; import { elide } from "./util.js"; +const REASONING_PREFIX = "reasoning:"; + +function shouldSuppressReasoningReply(payload: ReplyPayload): boolean { + if (payload.isReasoning === true) { + return true; + } + const text = payload.text; + if (typeof text !== "string") { + return false; + } + return text.trimStart().toLowerCase().startsWith(REASONING_PREFIX); +} + export async function deliverWebReply(params: { replyResult: ReplyPayload; msg: WebInboundMsg; @@ -29,6 +42,10 @@ export async function deliverWebReply(params: { }) { const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params; const replyStarted = Date.now(); + if (shouldSuppressReasoningReply(replyResult)) { + whatsappOutboundLog.debug(`Suppressed reasoning payload to ${msg.from}`); + return; + } const tableMode = params.tableMode ?? "code"; const chunkMode = params.chunkMode ?? "length"; const convertedText = markdownToWhatsApp( From b35d00aaf8b68c77e9d4ef6ef4e6101acf830b7a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:36:36 +0000 Subject: [PATCH 335/408] fix: sanitize Gemini 3.1 Google reasoning payloads --- CHANGELOG.md | 1 + ...i-embedded-runner-extraparams.live.test.ts | 171 ++++++++++++++++++ .../pi-embedded-runner-extraparams.test.ts | 96 ++++++++++ src/agents/pi-embedded-runner/extra-params.ts | 92 ++++++++++ 4 files changed, 360 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32de2c31f6ca..b9654c1c218f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width `HOOK:...`) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3. - Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3. - Providers/OpenRouter/Auth profiles: bypass auth-profile cooldown/disable windows for OpenRouter, so provider failures no longer put OpenRouter profiles into local cooldown and stale legacy cooldown markers are ignored in fallback and status selection paths. (#25892) Thanks @alexanderatallah for raising this and @vincentkoc for the fix. +- Providers/Google reasoning: sanitize invalid negative `thinkingBudget` payloads for Gemini 3.1 requests by dropping `-1` budgets and mapping configured reasoning effort to `thinkingLevel`, preventing malformed reasoning payloads on `google-generative-ai`. (#25900) - WhatsApp/Web reconnect: treat close status `440` as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson. - WhatsApp/Reasoning safety: suppress outbound payloads marked as reasoning and hard-drop text payloads that begin with `Reasoning:` before WhatsApp delivery, preventing hidden thinking blocks from leaking to end users through final-message paths. (#25804, #25214, #24328) - Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko. diff --git a/src/agents/pi-embedded-runner-extraparams.live.test.ts b/src/agents/pi-embedded-runner-extraparams.live.test.ts index 38c500cf60d8..8da5bef6f570 100644 --- a/src/agents/pi-embedded-runner-extraparams.live.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.live.test.ts @@ -6,9 +6,13 @@ import { isTruthyEnvValue } from "../infra/env.js"; import { applyExtraParamsToAgent } from "./pi-embedded-runner.js"; const OPENAI_KEY = process.env.OPENAI_API_KEY ?? ""; +const GEMINI_KEY = process.env.GEMINI_API_KEY ?? ""; const LIVE = isTruthyEnvValue(process.env.OPENAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE); +const GEMINI_LIVE = + isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE); const describeLive = LIVE && OPENAI_KEY ? describe : describe.skip; +const describeGeminiLive = GEMINI_LIVE && GEMINI_KEY ? describe : describe.skip; describeLive("pi embedded extra params (live)", () => { it("applies config maxTokens to openai streamFn", async () => { @@ -62,3 +66,170 @@ describeLive("pi embedded extra params (live)", () => { expect(outputTokens ?? 0).toBeLessThanOrEqual(20); }, 30_000); }); + +describeGeminiLive("pi embedded extra params (gemini live)", () => { + function isGoogleModelUnavailableError(raw: string | undefined): boolean { + const msg = (raw ?? "").toLowerCase(); + if (!msg) { + return false; + } + return ( + msg.includes("not found") || + msg.includes("404") || + msg.includes("not_available") || + msg.includes("permission denied") || + msg.includes("unsupported model") + ); + } + + function isGoogleImageProcessingError(raw: string | undefined): boolean { + const msg = (raw ?? "").toLowerCase(); + if (!msg) { + return false; + } + return ( + msg.includes("unable to process input image") || + msg.includes("invalid_argument") || + msg.includes("bad request") + ); + } + + async function runGeminiProbe(params: { + agentStreamFn: typeof streamSimple; + model: Model<"google-generative-ai">; + apiKey: string; + oneByOneRedPngBase64: string; + includeImage?: boolean; + prompt: string; + onPayload?: (payload: Record) => void; + }): Promise<{ sawDone: boolean; stopReason?: string; errorMessage?: string }> { + const userContent: Array< + { type: "text"; text: string } | { type: "image"; mimeType: string; data: string } + > = [{ type: "text", text: params.prompt }]; + if (params.includeImage ?? true) { + userContent.push({ + type: "image", + mimeType: "image/png", + data: params.oneByOneRedPngBase64, + }); + } + + const stream = params.agentStreamFn( + params.model, + { + messages: [ + { + role: "user", + content: userContent, + timestamp: Date.now(), + }, + ], + }, + { + apiKey: params.apiKey, + reasoning: "high", + maxTokens: 64, + onPayload: (payload) => { + params.onPayload?.(payload as Record); + }, + }, + ); + + let sawDone = false; + let stopReason: string | undefined; + let errorMessage: string | undefined; + + for await (const event of stream) { + if (event.type === "done") { + sawDone = true; + stopReason = event.reason; + } else if (event.type === "error") { + stopReason = event.reason; + errorMessage = event.error?.errorMessage; + } + } + + return { sawDone, stopReason, errorMessage }; + } + + it("sanitizes Gemini 3.1 thinking payload and keeps image parts with reasoning enabled", async () => { + const model = getModel( + "google", + "gemini-3.1-pro-preview", + ) as unknown as Model<"google-generative-ai">; + + const agent = { streamFn: streamSimple }; + applyExtraParamsToAgent(agent, undefined, "google", model.id, undefined, "high"); + + const oneByOneRedPngBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGP4zwAAAgIBAJBzWgkAAAAASUVORK5CYII="; + + let capturedPayload: Record | undefined; + const imageResult = await runGeminiProbe({ + agentStreamFn: agent.streamFn, + model, + apiKey: GEMINI_KEY, + oneByOneRedPngBase64, + includeImage: true, + prompt: "What color is this image? Reply with one word.", + onPayload: (payload) => { + capturedPayload = payload; + }, + }); + + expect(capturedPayload).toBeDefined(); + const thinkingConfig = ( + capturedPayload?.config as { thinkingConfig?: Record } | undefined + )?.thinkingConfig; + expect(thinkingConfig?.thinkingBudget).toBeUndefined(); + expect(thinkingConfig?.thinkingLevel).toBe("HIGH"); + + const imagePart = ( + capturedPayload?.contents as + | Array<{ parts?: Array<{ inlineData?: { mimeType?: string; data?: string } }> }> + | undefined + )?.[0]?.parts?.find((part) => part.inlineData !== undefined)?.inlineData; + expect(imagePart).toEqual({ + mimeType: "image/png", + data: oneByOneRedPngBase64, + }); + + if (!imageResult.sawDone && !isGoogleModelUnavailableError(imageResult.errorMessage)) { + expect(isGoogleImageProcessingError(imageResult.errorMessage)).toBe(true); + } + + const textResult = await runGeminiProbe({ + agentStreamFn: agent.streamFn, + model, + apiKey: GEMINI_KEY, + oneByOneRedPngBase64, + includeImage: false, + prompt: "Reply with exactly OK.", + }); + + if (!textResult.sawDone && isGoogleModelUnavailableError(textResult.errorMessage)) { + // Some keys/regions do not expose Gemini 3.1 preview. Fall back to a + // stable model to keep live reasoning verification active. + const fallbackModel = getModel( + "google", + "gemini-2.5-pro", + ) as unknown as Model<"google-generative-ai">; + const fallback = await runGeminiProbe({ + agentStreamFn: agent.streamFn, + model: fallbackModel, + apiKey: GEMINI_KEY, + oneByOneRedPngBase64, + includeImage: false, + prompt: "Reply with exactly OK.", + }); + expect(fallback.sawDone).toBe(true); + expect(fallback.stopReason).toBeDefined(); + expect(fallback.stopReason).not.toBe("error"); + return; + } + + expect(textResult.sawDone).toBe(true); + expect(textResult.stopReason).toBeDefined(); + expect(textResult.stopReason).not.toBe("error"); + }, 45_000); +}); diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 4392edfb3e18..404d4439da44 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -372,6 +372,102 @@ describe("applyExtraParamsToAgent", () => { expect(payloads[0]?.thinking).toBe("off"); }); + it("removes invalid negative Google thinkingBudget and maps Gemini 3.1 to thinkingLevel", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + contents: [ + { + role: "user", + parts: [ + { text: "describe image" }, + { + inlineData: { + mimeType: "image/png", + data: "ZmFrZQ==", + }, + }, + ], + }, + ], + config: { + thinkingConfig: { + includeThoughts: true, + thinkingBudget: -1, + }, + }, + }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "atproxy", "gemini-3.1-pro-high", undefined, "high"); + + const model = { + api: "google-generative-ai", + provider: "atproxy", + id: "gemini-3.1-pro-high", + } as Model<"google-generative-ai">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + const thinkingConfig = ( + payloads[0]?.config as { thinkingConfig?: Record } | undefined + )?.thinkingConfig; + expect(thinkingConfig).toEqual({ + includeThoughts: true, + thinkingLevel: "HIGH", + }); + expect( + ( + payloads[0]?.contents as + | Array<{ parts?: Array<{ inlineData?: { mimeType?: string; data?: string } }> }> + | undefined + )?.[0]?.parts?.[1]?.inlineData, + ).toEqual({ + mimeType: "image/png", + data: "ZmFrZQ==", + }); + }); + + it("keeps valid Google thinkingBudget unchanged", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + config: { + thinkingConfig: { + includeThoughts: true, + thinkingBudget: 2048, + }, + }, + }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "atproxy", "gemini-3.1-pro-high", undefined, "high"); + + const model = { + api: "google-generative-ai", + provider: "atproxy", + id: "gemini-3.1-pro-high", + } as Model<"google-generative-ai">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.config).toEqual({ + thinkingConfig: { + includeThoughts: true, + thinkingBudget: 2048, + }, + }); + }); it("adds OpenRouter attribution headers to stream options", () => { const { calls, agent } = createOptionsCaptureAgent(); diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 05c764d15c7c..2e87dcee608b 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -504,6 +504,94 @@ function createOpenRouterWrapper( }; } +function isGemini31Model(modelId: string): boolean { + const normalized = modelId.toLowerCase(); + return normalized.includes("gemini-3.1-pro") || normalized.includes("gemini-3.1-flash"); +} + +function mapThinkLevelToGoogleThinkingLevel( + thinkingLevel: ThinkLevel, +): "MINIMAL" | "LOW" | "MEDIUM" | "HIGH" | undefined { + switch (thinkingLevel) { + case "minimal": + return "MINIMAL"; + case "low": + return "LOW"; + case "medium": + return "MEDIUM"; + case "high": + case "xhigh": + return "HIGH"; + default: + return undefined; + } +} + +function sanitizeGoogleThinkingPayload(params: { + payload: unknown; + modelId?: string; + thinkingLevel?: ThinkLevel; +}): void { + if (!params.payload || typeof params.payload !== "object") { + return; + } + const payloadObj = params.payload as Record; + const config = payloadObj.config; + if (!config || typeof config !== "object") { + return; + } + const configObj = config as Record; + const thinkingConfig = configObj.thinkingConfig; + if (!thinkingConfig || typeof thinkingConfig !== "object") { + return; + } + const thinkingConfigObj = thinkingConfig as Record; + const thinkingBudget = thinkingConfigObj.thinkingBudget; + if (typeof thinkingBudget !== "number" || thinkingBudget >= 0) { + return; + } + + // pi-ai can emit thinkingBudget=-1 for some Gemini 3.1 IDs; a negative budget + // is invalid for Google-compatible backends and can lead to malformed handling. + delete thinkingConfigObj.thinkingBudget; + + if ( + typeof params.modelId === "string" && + isGemini31Model(params.modelId) && + params.thinkingLevel && + params.thinkingLevel !== "off" && + thinkingConfigObj.thinkingLevel === undefined + ) { + const mappedLevel = mapThinkLevelToGoogleThinkingLevel(params.thinkingLevel); + if (mappedLevel) { + thinkingConfigObj.thinkingLevel = mappedLevel; + } + } +} + +function createGoogleThinkingPayloadWrapper( + baseStreamFn: StreamFn | undefined, + thinkingLevel?: ThinkLevel, +): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + const onPayload = options?.onPayload; + return underlying(model, context, { + ...options, + onPayload: (payload) => { + if (model.api === "google-generative-ai") { + sanitizeGoogleThinkingPayload({ + payload, + modelId: model.id, + thinkingLevel, + }); + } + onPayload?.(payload); + }, + }); + }; +} + /** * Create a streamFn wrapper that injects tool_stream=true for Z.AI providers. * @@ -615,6 +703,10 @@ export function applyExtraParamsToAgent( } } + // Guard Google payloads against invalid negative thinking budgets emitted by + // upstream model-ID heuristics for Gemini 3.1 variants. + agent.streamFn = createGoogleThinkingPayloadWrapper(agent.streamFn, thinkingLevel); + // Work around upstream pi-ai hardcoding `store: false` for Responses API. // Force `store=true` for direct OpenAI/OpenAI Codex providers so multi-turn // server-side conversation state is preserved. From 5e3502df5fbf8b9744cc93f112d14c8f7d6d7bf8 Mon Sep 17 00:00:00 2001 From: Albert Lie Date: Tue, 24 Feb 2026 18:45:48 -0500 Subject: [PATCH 336/408] fix(sandbox): prevent shell option interpretation for paths with leading hyphens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paths starting with "-" (like those containing "---" pattern) can be interpreted as shell options by the sh shell. This fix adds a helper function that prepends "./" to paths starting with "-" to prevent this interpretation. This fixes the issue where sandbox filesystem operations fail with "Syntax error: ; unexpected" when file paths contain the "---" pattern used in auto-generated inbound media filenames like: file_1095---f00a04a2-99a0-4d98-99b0-dfe61c5a4198.ogg 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/agents/sandbox/fs-bridge.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts index dee44e1b2376..1d0d4266b20c 100644 --- a/src/agents/sandbox/fs-bridge.ts +++ b/src/agents/sandbox/fs-bridge.ts @@ -96,7 +96,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { const target = this.resolveResolvedPath(params); await this.assertPathSafety(target, { action: "read files" }); const result = await this.runCommand('set -eu; cat -- "$1"', { - args: [target.containerPath], + args: [ensurePathNotInterpretedAsOption(target.containerPath)], signal: params.signal, }); return result.stdout; @@ -121,7 +121,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { ? 'set -eu; cat >"$1"' : 'set -eu; dir=$(dirname -- "$1"); if [ "$dir" != "." ]; then mkdir -p -- "$dir"; fi; cat >"$1"'; await this.runCommand(script, { - args: [target.containerPath], + args: [ensurePathNotInterpretedAsOption(target.containerPath)], stdin: buffer, signal: params.signal, }); @@ -132,7 +132,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { this.ensureWriteAccess(target, "create directories"); await this.assertPathSafety(target, { action: "create directories", requireWritable: true }); await this.runCommand('set -eu; mkdir -p -- "$1"', { - args: [target.containerPath], + args: [ensurePathNotInterpretedAsOption(target.containerPath)], signal: params.signal, }); } @@ -156,7 +156,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { ); const rmCommand = flags.length > 0 ? `rm ${flags.join(" ")}` : "rm"; await this.runCommand(`set -eu; ${rmCommand} -- "$1"`, { - args: [target.containerPath], + args: [ensurePathNotInterpretedAsOption(target.containerPath)], signal: params.signal, }); } @@ -183,7 +183,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { await this.runCommand( 'set -eu; dir=$(dirname -- "$2"); if [ "$dir" != "." ]; then mkdir -p -- "$dir"; fi; mv -- "$1" "$2"', { - args: [from.containerPath, to.containerPath], + args: [ensurePathNotInterpretedAsOption(from.containerPath), ensurePathNotInterpretedAsOption(to.containerPath)], signal: params.signal, }, ); @@ -197,7 +197,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { const target = this.resolveResolvedPath(params); await this.assertPathSafety(target, { action: "stat files" }); const result = await this.runCommand('set -eu; stat -c "%F|%s|%Y" -- "$1"', { - args: [target.containerPath], + args: [ensurePathNotInterpretedAsOption(target.containerPath)], signal: params.signal, allowFailure: true, }); @@ -307,7 +307,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { 'printf "%s%s\\n" "$canonical" "$suffix"', ].join("\n"); const result = await this.runCommand(script, { - args: [params.containerPath, params.allowFinalSymlink ? "1" : "0"], + args: [ensurePathNotInterpretedAsOption(params.containerPath), params.allowFinalSymlink ? "1" : "0"], }); const canonical = result.stdout.toString("utf8").trim(); if (!canonical.startsWith("/")) { @@ -363,6 +363,19 @@ function isPathInsidePosix(root: string, target: string): boolean { return target === root || target.startsWith(`${root}/`); } +/** + * Ensure the path is not interpreted as a shell option. + * Paths starting with "-" can be interpreted as command options by the shell. + * Prepend "./" to prevent this interpretation. + */ +function ensurePathNotInterpretedAsOption(path: string): string { + // If path starts with a hyphen (either - or --), prepend ./ to prevent interpretation as option + if (path.startsWith("-") || path.startsWith("--")) { + return "./" + path; + } + return path; +} + async function assertNoHostSymlinkEscape(params: { absolutePath: string; rootPath: string; From c7ae4ed04dcfb6830cdc381d0c9ad1455c7bebb4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:39:56 +0000 Subject: [PATCH 337/408] fix: harden sandbox fs dash-path regression coverage (#25891) (thanks @albertlieyingadrian) --- CHANGELOG.md | 1 + src/agents/sandbox/fs-bridge.test.ts | 11 +++++++++++ src/agents/sandbox/fs-bridge.ts | 27 +++++++-------------------- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9654c1c218f..2f227da4c9ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai - Discord/Voice reliability: restore runtime DAVE dependency (`@snazzah/davey`), add configurable DAVE join options (`channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance`), clean up voice listeners/session teardown, guard against stale connection events, and trigger controlled rejoin recovery after repeated decrypt failures to improve inbound STT stability under DAVE receive errors. (#25861, #25372, #24883, #24825, #23890, #23105, #22961, #23421, #23278, #23032) - Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall. - Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility. +- Sandbox/FS bridge tests: add regression coverage for dash-leading basenames to confirm sandbox file reads resolve to absolute container paths (and avoid shell-option misdiagnosis for dashed filenames). (#25891) Thanks @albertlieyingadrian. - Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. - Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch. - Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts index 98744f3562d0..aa3144ca3315 100644 --- a/src/agents/sandbox/fs-bridge.test.ts +++ b/src/agents/sandbox/fs-bridge.test.ts @@ -123,6 +123,17 @@ describe("sandbox fs bridge shell compatibility", () => { expect(readPath).toContain("file_1095---"); }); + it("resolves dash-leading basenames into absolute container paths", async () => { + const bridge = createSandboxFsBridge({ sandbox: createSandbox() }); + + await bridge.readFile({ filePath: "--leading.txt" }); + + const readCall = findCallByScriptFragment('cat -- "$1"'); + expect(readCall).toBeDefined(); + const readPath = readCall ? getDockerPathArg(readCall[0]) : ""; + expect(readPath).toBe("/workspace/--leading.txt"); + }); + it("resolves bind-mounted absolute container paths for reads", async () => { const sandbox = createSandbox({ docker: { diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts index 1d0d4266b20c..dee44e1b2376 100644 --- a/src/agents/sandbox/fs-bridge.ts +++ b/src/agents/sandbox/fs-bridge.ts @@ -96,7 +96,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { const target = this.resolveResolvedPath(params); await this.assertPathSafety(target, { action: "read files" }); const result = await this.runCommand('set -eu; cat -- "$1"', { - args: [ensurePathNotInterpretedAsOption(target.containerPath)], + args: [target.containerPath], signal: params.signal, }); return result.stdout; @@ -121,7 +121,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { ? 'set -eu; cat >"$1"' : 'set -eu; dir=$(dirname -- "$1"); if [ "$dir" != "." ]; then mkdir -p -- "$dir"; fi; cat >"$1"'; await this.runCommand(script, { - args: [ensurePathNotInterpretedAsOption(target.containerPath)], + args: [target.containerPath], stdin: buffer, signal: params.signal, }); @@ -132,7 +132,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { this.ensureWriteAccess(target, "create directories"); await this.assertPathSafety(target, { action: "create directories", requireWritable: true }); await this.runCommand('set -eu; mkdir -p -- "$1"', { - args: [ensurePathNotInterpretedAsOption(target.containerPath)], + args: [target.containerPath], signal: params.signal, }); } @@ -156,7 +156,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { ); const rmCommand = flags.length > 0 ? `rm ${flags.join(" ")}` : "rm"; await this.runCommand(`set -eu; ${rmCommand} -- "$1"`, { - args: [ensurePathNotInterpretedAsOption(target.containerPath)], + args: [target.containerPath], signal: params.signal, }); } @@ -183,7 +183,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { await this.runCommand( 'set -eu; dir=$(dirname -- "$2"); if [ "$dir" != "." ]; then mkdir -p -- "$dir"; fi; mv -- "$1" "$2"', { - args: [ensurePathNotInterpretedAsOption(from.containerPath), ensurePathNotInterpretedAsOption(to.containerPath)], + args: [from.containerPath, to.containerPath], signal: params.signal, }, ); @@ -197,7 +197,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { const target = this.resolveResolvedPath(params); await this.assertPathSafety(target, { action: "stat files" }); const result = await this.runCommand('set -eu; stat -c "%F|%s|%Y" -- "$1"', { - args: [ensurePathNotInterpretedAsOption(target.containerPath)], + args: [target.containerPath], signal: params.signal, allowFailure: true, }); @@ -307,7 +307,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { 'printf "%s%s\\n" "$canonical" "$suffix"', ].join("\n"); const result = await this.runCommand(script, { - args: [ensurePathNotInterpretedAsOption(params.containerPath), params.allowFinalSymlink ? "1" : "0"], + args: [params.containerPath, params.allowFinalSymlink ? "1" : "0"], }); const canonical = result.stdout.toString("utf8").trim(); if (!canonical.startsWith("/")) { @@ -363,19 +363,6 @@ function isPathInsidePosix(root: string, target: string): boolean { return target === root || target.startsWith(`${root}/`); } -/** - * Ensure the path is not interpreted as a shell option. - * Paths starting with "-" can be interpreted as command options by the shell. - * Prepend "./" to prevent this interpretation. - */ -function ensurePathNotInterpretedAsOption(path: string): string { - // If path starts with a hyphen (either - or --), prepend ./ to prevent interpretation as option - if (path.startsWith("-") || path.startsWith("--")) { - return "./" + path; - } - return path; -} - async function assertNoHostSymlinkEscape(params: { absolutePath: string; rootPath: string; From 455fbc6b6def750dce976854874ac8b146dd1bf1 Mon Sep 17 00:00:00 2001 From: Brandon Wise Date: Mon, 23 Feb 2026 10:18:34 -0500 Subject: [PATCH 338/408] fix(security): prevent cross-channel reply routing in shared sessions --- src/infra/outbound/agent-delivery.ts | 28 ++++++++++ src/infra/outbound/targets.test.ts | 76 ++++++++++++++++++++++++++++ src/infra/outbound/targets.ts | 36 +++++++++++-- 3 files changed, 136 insertions(+), 4 deletions(-) diff --git a/src/infra/outbound/agent-delivery.ts b/src/infra/outbound/agent-delivery.ts index 7c856598d2d6..b2e94a99247a 100644 --- a/src/infra/outbound/agent-delivery.ts +++ b/src/infra/outbound/agent-delivery.ts @@ -7,6 +7,7 @@ import { isDeliverableMessageChannel, isGatewayMessageChannel, normalizeMessageChannel, + type DeliverableMessageChannel, type GatewayMessageChannel, } from "../../utils/message-channel.js"; import type { OutboundTargetResolution } from "./targets.js"; @@ -32,6 +33,20 @@ export function resolveAgentDeliveryPlan(params: { explicitThreadId?: string | number; accountId?: string; wantsDelivery: boolean; + /** + * The channel that originated the current agent turn. When provided, + * overrides session-level `lastChannel` to prevent cross-channel reply + * routing in shared sessions (dmScope="main"). + * + * @see https://github.com/openclaw/openclaw/issues/24152 + */ + turnSourceChannel?: string; + /** Turn-source `to` — paired with `turnSourceChannel`. */ + turnSourceTo?: string; + /** Turn-source `accountId` — paired with `turnSourceChannel`. */ + turnSourceAccountId?: string; + /** Turn-source `threadId` — paired with `turnSourceChannel`. */ + turnSourceThreadId?: string | number; }): AgentDeliveryPlan { const requestedRaw = typeof params.requestedChannel === "string" ? params.requestedChannel.trim() : ""; @@ -43,11 +58,24 @@ export function resolveAgentDeliveryPlan(params: { ? params.explicitTo.trim() : undefined; + // Resolve turn-source channel for cross-channel safety. + const normalizedTurnSource = params.turnSourceChannel + ? normalizeMessageChannel(params.turnSourceChannel) + : undefined; + const turnSourceChannel = + normalizedTurnSource && isDeliverableMessageChannel(normalizedTurnSource) + ? normalizedTurnSource + : undefined; + const baseDelivery = resolveSessionDeliveryTarget({ entry: params.sessionEntry, requestedChannel: requestedChannel === INTERNAL_MESSAGE_CHANNEL ? "last" : requestedChannel, explicitTo, explicitThreadId: params.explicitThreadId, + turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, }); const resolvedChannel = (() => { diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 5cc004a4b3a6..c9976414e21b 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -357,3 +357,79 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.threadId).toBe(1008013); }); }); + +describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", () => { + it("uses turnSourceChannel over session lastChannel when provided", () => { + // Simulate: WhatsApp message originated the turn, but a Slack message + // arrived concurrently and updated lastChannel to "slack" + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-shared", + updatedAt: 1, + lastChannel: "slack", // <- concurrently overwritten + lastTo: "U0AEMECNCBV", // <- Slack user (wrong target) + }, + requestedChannel: "last", + turnSourceChannel: "whatsapp", // <- originated from WhatsApp + turnSourceTo: "+66972796305", // <- WhatsApp user (correct target) + }); + + expect(resolved.channel).toBe("whatsapp"); + expect(resolved.to).toBe("+66972796305"); + }); + + it("falls back to session lastChannel when turnSourceChannel is not set", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-normal", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "8587265585", + }, + requestedChannel: "last", + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("8587265585"); + }); + + it("respects explicit requestedChannel over turnSourceChannel", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-explicit", + updatedAt: 1, + lastChannel: "slack", + lastTo: "U12345", + }, + requestedChannel: "telegram", + explicitTo: "8587265585", + turnSourceChannel: "whatsapp", + turnSourceTo: "+66972796305", + }); + + // Explicit requestedChannel "telegram" is not "last", so it takes priority + expect(resolved.channel).toBe("telegram"); + }); + + it("preserves turnSourceAccountId and turnSourceThreadId", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-meta", + updatedAt: 1, + lastChannel: "slack", + lastTo: "U_WRONG", + lastAccountId: "wrong-account", + }, + requestedChannel: "last", + turnSourceChannel: "telegram", + turnSourceTo: "8587265585", + turnSourceAccountId: "bot-123", + turnSourceThreadId: 42, + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("8587265585"); + expect(resolved.accountId).toBe("bot-123"); + expect(resolved.threadId).toBe(42); + }); +}); diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index f03918423e2b..6df0ecee6d20 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -62,13 +62,41 @@ export function resolveSessionDeliveryTarget(params: { fallbackChannel?: DeliverableMessageChannel; allowMismatchedLastTo?: boolean; mode?: ChannelOutboundTargetMode; + /** + * When set, this overrides the session-level `lastChannel` for "last" + * resolution. This prevents cross-channel reply routing when multiple + * channels share the same session (dmScope = "main") and an inbound + * message from a different channel updates `lastChannel` while an agent + * turn is still in flight. + * + * Callers should set this to the channel that originated the current + * agent turn so the reply always routes back to the correct channel. + * + * @see https://github.com/openclaw/openclaw/issues/24152 + */ + turnSourceChannel?: DeliverableMessageChannel; + /** Turn-source `to` — paired with `turnSourceChannel`. */ + turnSourceTo?: string; + /** Turn-source `accountId` — paired with `turnSourceChannel`. */ + turnSourceAccountId?: string; + /** Turn-source `threadId` — paired with `turnSourceChannel`. */ + turnSourceThreadId?: string | number; }): SessionDeliveryTarget { const context = deliveryContextFromSession(params.entry); - const lastChannel = + const sessionLastChannel = context?.channel && isDeliverableMessageChannel(context.channel) ? context.channel : undefined; - const lastTo = context?.to; - const lastAccountId = context?.accountId; - const lastThreadId = context?.threadId; + + // When a turn-source channel is provided, use it instead of the session's + // mutable lastChannel. This prevents a concurrent inbound from a different + // channel from hijacking the reply target (cross-channel privacy leak). + const lastChannel = params.turnSourceChannel ?? sessionLastChannel; + const lastTo = params.turnSourceChannel ? (params.turnSourceTo ?? context?.to) : context?.to; + const lastAccountId = params.turnSourceChannel + ? (params.turnSourceAccountId ?? context?.accountId) + : context?.accountId; + const lastThreadId = params.turnSourceChannel + ? (params.turnSourceThreadId ?? context?.threadId) + : context?.threadId; const rawRequested = params.requestedChannel ?? "last"; const requested = rawRequested === "last" ? "last" : normalizeMessageChannel(rawRequested); From f35c902bd6c09dc471be941ca1ccbb935aca7ed7 Mon Sep 17 00:00:00 2001 From: Brandon Wise Date: Mon, 23 Feb 2026 10:35:29 -0500 Subject: [PATCH 339/408] style: fix oxfmt formatting in targets.test.ts --- src/infra/outbound/targets.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index c9976414e21b..52d820437565 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -366,12 +366,12 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", entry: { sessionId: "sess-shared", updatedAt: 1, - lastChannel: "slack", // <- concurrently overwritten - lastTo: "U0AEMECNCBV", // <- Slack user (wrong target) + lastChannel: "slack", // <- concurrently overwritten + lastTo: "U0AEMECNCBV", // <- Slack user (wrong target) }, requestedChannel: "last", - turnSourceChannel: "whatsapp", // <- originated from WhatsApp - turnSourceTo: "+66972796305", // <- WhatsApp user (correct target) + turnSourceChannel: "whatsapp", // <- originated from WhatsApp + turnSourceTo: "+66972796305", // <- WhatsApp user (correct target) }); expect(resolved.channel).toBe("whatsapp"); From 389ccda0f6d0e07cb10da4a2c2b50e3f9073b10b Mon Sep 17 00:00:00 2001 From: Brandon Wise Date: Mon, 23 Feb 2026 13:24:14 -0500 Subject: [PATCH 340/408] fix: remove unused DeliverableMessageChannel import --- src/infra/outbound/agent-delivery.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/infra/outbound/agent-delivery.ts b/src/infra/outbound/agent-delivery.ts index b2e94a99247a..2600a076014d 100644 --- a/src/infra/outbound/agent-delivery.ts +++ b/src/infra/outbound/agent-delivery.ts @@ -7,7 +7,6 @@ import { isDeliverableMessageChannel, isGatewayMessageChannel, normalizeMessageChannel, - type DeliverableMessageChannel, type GatewayMessageChannel, } from "../../utils/message-channel.js"; import type { OutboundTargetResolution } from "./targets.js"; From 559b5eab71dcd000c592033661af1bde7500dc43 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:41:49 +0000 Subject: [PATCH 341/408] fix(cli): support --query in memory search command (#25904) --- CHANGELOG.md | 1 + src/cli/memory-cli.test.ts | 43 ++++++++++++++++++++++++++++++++++++++ src/cli/memory-cli.ts | 14 +++++++++++-- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f227da4c9ea..a528cbfcb71b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ Docs: https://docs.openclaw.ai - macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. - Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd. - CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. +- CLI/Memory search: accept `--query ` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky. - Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey. - Doctor/Plugins: auto-enable now resolves third-party channel plugins by manifest plugin id (not channel id), preventing invalid `plugins.entries.` writes when ids differ. (#25275) Thanks @zerone0x. diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts index 8a83bc5e906c..3d6dfa7d2a23 100644 --- a/src/cli/memory-cli.test.ts +++ b/src/cli/memory-cli.test.ts @@ -382,6 +382,49 @@ describe("memory cli", () => { expect(close).toHaveBeenCalled(); }); + it("accepts --query for memory search", async () => { + const close = vi.fn(async () => {}); + const search = vi.fn(async () => []); + mockManager({ search, close }); + + const log = spyRuntimeLogs(); + await runMemoryCli(["search", "--query", "deployment notes"]); + + expect(search).toHaveBeenCalledWith("deployment notes", { + maxResults: undefined, + minScore: undefined, + }); + expect(log).toHaveBeenCalledWith("No matches."); + expect(close).toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + it("prefers --query when positional and flag are both provided", async () => { + const close = vi.fn(async () => {}); + const search = vi.fn(async () => []); + mockManager({ search, close }); + + spyRuntimeLogs(); + await runMemoryCli(["search", "positional", "--query", "flagged"]); + + expect(search).toHaveBeenCalledWith("flagged", { + maxResults: undefined, + minScore: undefined, + }); + expect(close).toHaveBeenCalled(); + }); + + it("fails when neither positional query nor --query is provided", async () => { + const error = spyRuntimeErrors(); + await runMemoryCli(["search"]); + + expect(error).toHaveBeenCalledWith( + "Missing search query. Provide a positional query or use --query .", + ); + expect(getMemorySearchManager).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + it("prints search results as json when requested", async () => { const close = vi.fn(async () => {}); const search = vi.fn(async () => [ diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts index 6449653f8ac2..f530d5b510e4 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -702,19 +702,29 @@ export function registerMemoryCli(program: Command) { memory .command("search") .description("Search memory files") - .argument("", "Search query") + .argument("[query]", "Search query") + .option("--query ", "Search query (alternative to positional argument)") .option("--agent ", "Agent id (default: default agent)") .option("--max-results ", "Max results", (value: string) => Number(value)) .option("--min-score ", "Minimum score", (value: string) => Number(value)) .option("--json", "Print JSON") .action( async ( - query: string, + queryArg: string | undefined, opts: MemoryCommandOptions & { + query?: string; maxResults?: number; minScore?: number; }, ) => { + const query = opts.query ?? queryArg; + if (!query) { + defaultRuntime.error( + "Missing search query. Provide a positional query or use --query .", + ); + process.exitCode = 1; + return; + } const cfg = loadConfig(); const agentId = resolveAgent(cfg, opts.agent); await withMemoryManagerForAgent({ From bf5a96ad63c0a946c5887f92641df416750697f1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:46:20 +0000 Subject: [PATCH 342/408] fix(agents): keep fallback chain reachable on configured fallback models (#25922) --- CHANGELOG.md | 1 + src/agents/model-fallback.test.ts | 38 +++++++++++++++++++++++++++++++ src/agents/model-fallback.ts | 22 ++++++++++++++---- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a528cbfcb71b..4b07860dbe36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - Models/Bedrock auth: normalize additional Bedrock provider aliases (`bedrock`, `aws-bedrock`, `aws_bedrock`, `amazon bedrock`) to canonical `amazon-bedrock`, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13. - Providers/SiliconFlow: normalize `thinking="off"` to `thinking: null` for `Pro/*` model payloads to avoid provider-side 400 loops and misleading compaction retries. (#25435) Thanks @Zjianru. - Gateway/Models: honor explicit `agents.defaults.models` allowlist refs even when bundled model catalog data is stale, synthesize missing allowlist entries in `models.list`, and allow `sessions.patch`/`/model` selection for those refs without false `model not allowed` errors. (#20291) Thanks @kensipe, @nikolasdehor, and @vincentkoc. +- Agents/Model fallback: when a run is currently on a configured fallback model, keep traversing the configured fallback chain instead of collapsing straight to primary-only, preventing dead-end failures when primary stays in cooldown. (#25922, #25912) Thanks @Taskle. - Control UI/Agents: inherit `agents.defaults.model.fallbacks` in the Overview fallback input when no per-agent model entry exists, while preserving explicit per-agent fallback overrides (including empty lists). (#25729, #25710) Thanks @Suko. - Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky. - Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire `messages.statusReactions.{emojis,timing}` into Discord reaction lifecycle control, and compact model-picker `custom_id` keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr. diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 6b5128d90eaf..f727ea5e9251 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -237,6 +237,44 @@ describe("runWithModelFallback", () => { ]); }); + it("keeps configured fallback chain when current model is a configured fallback", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["anthropic/claude-haiku-3-5", "openrouter/deepseek-chat"], + }, + }, + }, + }); + + const run = vi.fn().mockImplementation(async (provider: string, model: string) => { + if (provider === "anthropic" && model === "claude-haiku-3-5") { + throw Object.assign(new Error("rate-limited"), { status: 429 }); + } + if (provider === "openrouter" && model === "openrouter/deepseek-chat") { + return "ok"; + } + throw new Error(`unexpected fallback candidate: ${provider}/${model}`); + }); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-haiku-3-5", + run, + }); + + expect(result.result).toBe("ok"); + expect(result.provider).toBe("openrouter"); + expect(result.model).toBe("openrouter/deepseek-chat"); + expect(run.mock.calls).toEqual([ + ["anthropic", "claude-haiku-3-5"], + ["openrouter", "openrouter/deepseek-chat"], + ]); + }); + it("treats normalized default refs as primary and keeps configured fallback chain", async () => { const cfg = makeCfg({ agents: { diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index b00506025903..fc44165e0b24 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -206,12 +206,24 @@ function resolveFallbackCandidates(params: { if (params.fallbacksOverride !== undefined) { return params.fallbacksOverride; } - // Skip configured fallback chain when the user runs a non-default override. - // In that case, retry should return directly to configured primary. - if (!sameModelCandidate(normalizedPrimary, configuredPrimary)) { - return []; // Override model failed → go straight to configured default + const configuredFallbacks = resolveAgentModelFallbackValues( + params.cfg?.agents?.defaults?.model, + ); + if (sameModelCandidate(normalizedPrimary, configuredPrimary)) { + return configuredFallbacks; } - return resolveAgentModelFallbackValues(params.cfg?.agents?.defaults?.model); + // Preserve resilience after failover: when current model is one of the + // configured fallback refs, keep traversing the configured fallback chain. + const isConfiguredFallback = configuredFallbacks.some((raw) => { + const resolved = resolveModelRefFromString({ + raw: String(raw ?? ""), + defaultProvider, + aliasIndex, + }); + return resolved ? sameModelCandidate(resolved.ref, normalizedPrimary) : false; + }); + // Keep legacy override behavior for ad-hoc models outside configured chain. + return isConfiguredFallback ? configuredFallbacks : []; })(); for (const raw of modelFallbacks) { From fa525bf212807735c0a0fb926e416ac55f0dbe99 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:49:33 +0000 Subject: [PATCH 343/408] fix(shell): prefer PowerShell 7 on Windows with tested fallbacks (#25684) --- CHANGELOG.md | 1 + src/agents/shell-utils.test.ts | 98 +++++++++++++++++++++++++++++++++- src/agents/shell-utils.ts | 22 +++++++- 3 files changed, 118 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b07860dbe36..0f7015de9ab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - iMessage/Reasoning safety: harden iMessage echo suppression with outbound `messageId` matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb. - Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 `dev=0` stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false `Local media path is not safe to read` drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng. +- Windows/Exec shell selection: prefer PowerShell 7 (`pwsh`) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing `&&` command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x. - macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos. - macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl. - macOS/Voice wake routing: default forwarded voice-wake transcripts to the `webchat` channel (instead of ambiguous `last` routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18. diff --git a/src/agents/shell-utils.test.ts b/src/agents/shell-utils.test.ts index 25be7c7574ec..9716fb73c8d2 100644 --- a/src/agents/shell-utils.test.ts +++ b/src/agents/shell-utils.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; -import { getShellConfig, resolveShellFromPath } from "./shell-utils.js"; +import { getShellConfig, resolvePowerShellPath, resolveShellFromPath } from "./shell-utils.js"; const isWin = process.platform === "win32"; @@ -42,7 +42,8 @@ describe("getShellConfig", () => { if (isWin) { it("uses PowerShell on Windows", () => { const { shell } = getShellConfig(); - expect(shell.toLowerCase()).toContain("powershell"); + const normalized = shell.toLowerCase(); + expect(normalized.includes("powershell") || normalized.includes("pwsh")).toBe(true); }); return; } @@ -113,3 +114,96 @@ describe("resolveShellFromPath", () => { expect(resolveShellFromPath("bash")).toBeUndefined(); }); }); + +describe("resolvePowerShellPath", () => { + let envSnapshot: ReturnType; + const tempDirs: string[] = []; + + beforeEach(() => { + envSnapshot = captureEnv([ + "ProgramFiles", + "PROGRAMFILES", + "ProgramW6432", + "SystemRoot", + "WINDIR", + "PATH", + ]); + }); + + afterEach(() => { + envSnapshot.restore(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prefers PowerShell 7 in ProgramFiles", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-")); + tempDirs.push(base); + const pwsh7Dir = path.join(base, "PowerShell", "7"); + fs.mkdirSync(pwsh7Dir, { recursive: true }); + const pwsh7Path = path.join(pwsh7Dir, "pwsh.exe"); + fs.writeFileSync(pwsh7Path, ""); + + process.env.ProgramFiles = base; + process.env.PATH = ""; + delete process.env.ProgramW6432; + delete process.env.SystemRoot; + delete process.env.WINDIR; + + expect(resolvePowerShellPath()).toBe(pwsh7Path); + }); + + it("prefers ProgramW6432 PowerShell 7 when ProgramFiles lacks pwsh", () => { + const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-")); + const programW6432 = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pw6432-")); + tempDirs.push(programFiles, programW6432); + const pwsh7Dir = path.join(programW6432, "PowerShell", "7"); + fs.mkdirSync(pwsh7Dir, { recursive: true }); + const pwsh7Path = path.join(pwsh7Dir, "pwsh.exe"); + fs.writeFileSync(pwsh7Path, ""); + + process.env.ProgramFiles = programFiles; + process.env.ProgramW6432 = programW6432; + process.env.PATH = ""; + delete process.env.SystemRoot; + delete process.env.WINDIR; + + expect(resolvePowerShellPath()).toBe(pwsh7Path); + }); + + it("finds pwsh on PATH when not in standard install locations", () => { + const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-")); + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bin-")); + tempDirs.push(programFiles, binDir); + const pwshPath = path.join(binDir, "pwsh"); + fs.writeFileSync(pwshPath, ""); + fs.chmodSync(pwshPath, 0o755); + + process.env.ProgramFiles = programFiles; + process.env.PATH = binDir; + delete process.env.ProgramW6432; + delete process.env.SystemRoot; + delete process.env.WINDIR; + + expect(resolvePowerShellPath()).toBe(pwshPath); + }); + + it("falls back to Windows PowerShell 5.1 path when pwsh is unavailable", () => { + const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-")); + const sysRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sysroot-")); + tempDirs.push(programFiles, sysRoot); + const ps51Dir = path.join(sysRoot, "System32", "WindowsPowerShell", "v1.0"); + fs.mkdirSync(ps51Dir, { recursive: true }); + const ps51Path = path.join(ps51Dir, "powershell.exe"); + fs.writeFileSync(ps51Path, ""); + + process.env.ProgramFiles = programFiles; + process.env.SystemRoot = sysRoot; + process.env.PATH = ""; + delete process.env.ProgramW6432; + delete process.env.WINDIR; + + expect(resolvePowerShellPath()).toBe(ps51Path); + }); +}); diff --git a/src/agents/shell-utils.ts b/src/agents/shell-utils.ts index ca4faa30195a..a4a5dbc115ab 100644 --- a/src/agents/shell-utils.ts +++ b/src/agents/shell-utils.ts @@ -2,7 +2,27 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -function resolvePowerShellPath(): string { +export function resolvePowerShellPath(): string { + // Prefer PowerShell 7 when available; PS 5.1 lacks "&&" support. + const programFiles = process.env.ProgramFiles || process.env.PROGRAMFILES || "C:\\Program Files"; + const pwsh7 = path.join(programFiles, "PowerShell", "7", "pwsh.exe"); + if (fs.existsSync(pwsh7)) { + return pwsh7; + } + + const programW6432 = process.env.ProgramW6432; + if (programW6432 && programW6432 !== programFiles) { + const pwsh7Alt = path.join(programW6432, "PowerShell", "7", "pwsh.exe"); + if (fs.existsSync(pwsh7Alt)) { + return pwsh7Alt; + } + } + + const pwshInPath = resolveShellFromPath("pwsh"); + if (pwshInPath) { + return pwshInPath; + } + const systemRoot = process.env.SystemRoot || process.env.WINDIR; if (systemRoot) { const candidate = path.join( From a01849e163208dbe5132cdaa4a53c3683f89ccc1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:54:05 +0000 Subject: [PATCH 344/408] test(telegram): cover triple-dash inbound media path regression --- ...s-media-file-path-no-file-download.test.ts | 43 ++++++++++++++++++- src/telegram/bot.media.e2e-harness.ts | 14 +++--- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts index 8b2089a6984b..959b18bb7313 100644 --- a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts +++ b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import * as ssrf from "../infra/net/ssrf.js"; -import { onSpy, sendChatActionSpy } from "./bot.media.e2e-harness.js"; +import { onSpy, saveMediaBufferSpy, sendChatActionSpy } from "./bot.media.e2e-harness.js"; const cacheStickerSpy = vi.fn(); const getCachedStickerSpy = vi.fn(); @@ -184,6 +184,47 @@ describe("telegram inbound media", () => { INBOUND_MEDIA_TEST_TIMEOUT_MS, ); + it( + "keeps Telegram inbound media paths with triple-dash ids", + async () => { + const runtimeError = vi.fn(); + const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError }); + const fetchSpy = mockTelegramFileDownload({ + contentType: "image/jpeg", + bytes: new Uint8Array([0xff, 0xd8, 0xff, 0x00]), + }); + const inboundPath = "/tmp/media/inbound/file_1095---f00a04a2-99a0-4d98-99b0-dfe61c5a4198.jpg"; + saveMediaBufferSpy.mockResolvedValueOnce({ + id: "media", + path: inboundPath, + size: 4, + contentType: "image/jpeg", + }); + + try { + await handler({ + message: { + message_id: 1001, + chat: { id: 1234, type: "private" }, + photo: [{ file_id: "fid" }], + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ file_path: "photos/1.jpg" }), + }); + + expect(runtimeError).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0]?.[0] as { Body?: string; MediaPaths?: string[] }; + expect(payload.Body).toContain(""); + expect(payload.MediaPaths).toContain(inboundPath); + } finally { + fetchSpy.mockRestore(); + } + }, + INBOUND_MEDIA_TEST_TIMEOUT_MS, + ); + it("prefers proxyFetch over global fetch", async () => { const runtimeLog = vi.fn(); const runtimeError = vi.fn(); diff --git a/src/telegram/bot.media.e2e-harness.ts b/src/telegram/bot.media.e2e-harness.ts index 7fff9e1e2745..191f92744d24 100644 --- a/src/telegram/bot.media.e2e-harness.ts +++ b/src/telegram/bot.media.e2e-harness.ts @@ -6,6 +6,12 @@ export const middlewareUseSpy: Mock = vi.fn(); export const onSpy: Mock = vi.fn(); export const stopSpy: Mock = vi.fn(); export const sendChatActionSpy: Mock = vi.fn(); +export const saveMediaBufferSpy: Mock = vi.fn(async (buffer: Buffer, contentType?: string) => ({ + id: "media", + path: "/tmp/telegram-media", + size: buffer.byteLength, + contentType: contentType ?? "application/octet-stream", +})); type ApiStub = { config: { use: (arg: unknown) => void }; @@ -52,12 +58,8 @@ vi.mock("../media/store.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - saveMediaBuffer: vi.fn(async (buffer: Buffer, contentType?: string) => ({ - id: "media", - path: "/tmp/telegram-media", - size: buffer.byteLength, - contentType: contentType ?? "application/octet-stream", - })), + saveMediaBuffer: (...args: Parameters) => + saveMediaBufferSpy(...args), }; }); From 22689b9dc93175c7db6843bd3d1cee169d62ed68 Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Tue, 24 Feb 2026 14:26:17 -0700 Subject: [PATCH 345/408] fix(sandbox): reject hardlinked tmp media aliases --- src/agents/sandbox-paths.test.ts | 76 ++++++++++++++++++++++++++++++++ src/agents/sandbox-paths.ts | 21 +++++++++ 2 files changed, 97 insertions(+) diff --git a/src/agents/sandbox-paths.test.ts b/src/agents/sandbox-paths.test.ts index fd6998994b25..e25fd2b3a89f 100644 --- a/src/agents/sandbox-paths.test.ts +++ b/src/agents/sandbox-paths.test.ts @@ -150,6 +150,82 @@ describe("resolveSandboxedMediaSource", () => { }); }); + it("rejects hardlinked OpenClaw tmp paths to outside files", async () => { + if (process.platform === "win32") { + return; + } + const outsideDir = await fs.mkdtemp( + path.join(process.cwd(), "sandbox-media-hardlink-outside-"), + ); + const outsideFile = path.join(outsideDir, "outside-secret.txt"); + const hardlinkPath = path.join( + openClawTmpDir, + `sandbox-media-hardlink-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`, + ); + try { + if (isPathInside(openClawTmpDir, outsideFile)) { + return; + } + await fs.writeFile(outsideFile, "secret", "utf8"); + await fs.mkdir(openClawTmpDir, { recursive: true }); + try { + await fs.link(outsideFile, hardlinkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + await withSandboxRoot(async (sandboxDir) => { + await expectSandboxRejection(hardlinkPath, sandboxDir, /hard.?link|sandbox/i); + }); + } finally { + await fs.rm(hardlinkPath, { force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + + it("rejects symlinked OpenClaw tmp paths to hardlinked outside files", async () => { + if (process.platform === "win32") { + return; + } + const outsideDir = await fs.mkdtemp( + path.join(process.cwd(), "sandbox-media-hardlink-outside-"), + ); + const outsideFile = path.join(outsideDir, "outside-secret.txt"); + const hardlinkPath = path.join( + openClawTmpDir, + `sandbox-media-hardlink-target-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`, + ); + const symlinkPath = path.join( + openClawTmpDir, + `sandbox-media-hardlink-symlink-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`, + ); + try { + if (isPathInside(openClawTmpDir, outsideFile)) { + return; + } + await fs.writeFile(outsideFile, "secret", "utf8"); + await fs.mkdir(openClawTmpDir, { recursive: true }); + try { + await fs.link(outsideFile, hardlinkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + await fs.symlink(hardlinkPath, symlinkPath); + await withSandboxRoot(async (sandboxDir) => { + await expectSandboxRejection(symlinkPath, sandboxDir, /hard.?link|sandbox/i); + }); + } finally { + await fs.rm(symlinkPath, { force: true }); + await fs.rm(hardlinkPath, { force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + // Group 4: Passthrough it("passes HTTP URLs through unchanged", async () => { const result = await resolveSandboxedMediaSource({ diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index f5ae24ac16af..04f0230750c0 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -187,9 +187,30 @@ async function resolveAllowedTmpMediaPath(params: { return undefined; } await assertNoSymlinkEscape(path.relative(openClawTmpDir, resolved), openClawTmpDir); + await assertNoHardlinkedFinalPath(resolved, openClawTmpDir); return resolved; } +async function assertNoHardlinkedFinalPath(filePath: string, root: string): Promise { + let stat: Awaited>; + try { + stat = await fs.stat(filePath); + } catch (err) { + if (isNotFoundPathError(err)) { + return; + } + throw err; + } + if (!stat.isFile()) { + return; + } + if (stat.nlink > 1) { + throw new Error( + `Hardlinked tmp media path is not allowed under sandbox root (${shortPath(root)}): ${shortPath(filePath)}`, + ); + } +} + async function assertNoSymlinkEscape( relative: string, root: string, From 6fa7226a672e18ff99f589469fb84d7ad59faee3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:40:04 +0000 Subject: [PATCH 346/408] fix: add changelog thanks for #25820 (thanks @bmendonca3) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f7015de9ab3..4622e46eadb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. - Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from `last` to `none` (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851) - Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting. +- Security/Sandbox media: reject hard-linked OpenClaw tmp media aliases (including symlink-to-hardlink chains) during sandbox media path resolution to prevent out-of-sandbox inode alias reads. (#25820) Thanks @bmendonca3. - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. - Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. Thanks @GCXWLP for reporting. - Zalo/Group policy: enforce sender authorization for group messages with `groupPolicy` + `groupAllowFrom` (fallback to `allowFrom`), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. Thanks @tdjackey for reporting. From c736778b3fc927226722f8a35c49b656a81d227a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:58:13 +0000 Subject: [PATCH 347/408] fix: drop active heartbeat followups from queue (#25610, thanks @mcaxtr) Co-authored-by: Marcus Castro --- CHANGELOG.md | 1 + .../reply/agent-runner.runreplyagent.test.ts | 47 +++++++++++++++++-- src/auto-reply/reply/agent-runner.ts | 5 ++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4622e46eadb1..0e93bc017670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch. - Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. - Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from `last` to `none` (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851) +- Auto-reply/Heartbeat queueing: drop heartbeat runs when a session already has an active run instead of enqueueing a stale followup, preventing duplicate heartbeat response branches after queue drain. (#25610, #25606) Thanks @mcaxtr. - Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting. - Security/Sandbox media: reject hard-linked OpenClaw tmp media aliases (including symlink-to-hardlink chains) during sandbox media path resolution to prevent out-of-sandbox inode alias reads. (#25820) Thanks @bmendonca3. - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts index 3590a624ce81..52d1e4550c20 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts @@ -8,7 +8,7 @@ import type { TypingMode } from "../../config/types.js"; import { withStateDirEnv } from "../../test-helpers/state-dir-env.js"; import type { TemplateContext } from "../templating.js"; import type { GetReplyOptions } from "../types.js"; -import type { FollowupRun, QueueSettings } from "./queue.js"; +import { enqueueFollowupRun, type FollowupRun, type QueueSettings } from "./queue.js"; import { createMockTypingController } from "./test-helpers.js"; type AgentRunParams = { @@ -86,6 +86,7 @@ beforeAll(async () => { beforeEach(() => { state.runEmbeddedPiAgentMock.mockClear(); state.runCliAgentMock.mockClear(); + vi.mocked(enqueueFollowupRun).mockClear(); vi.stubEnv("OPENCLAW_TEST_FAST", "1"); }); @@ -98,6 +99,9 @@ function createMinimalRun(params?: { storePath?: string; typingMode?: TypingMode; blockStreamingEnabled?: boolean; + isActive?: boolean; + shouldFollowup?: boolean; + resolvedQueueMode?: string; runOverrides?: Partial; }) { const typing = createMockTypingController(); @@ -106,7 +110,9 @@ function createMinimalRun(params?: { Provider: "whatsapp", MessageSid: "msg", } as unknown as TemplateContext; - const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; + const resolvedQueue = { + mode: params?.resolvedQueueMode ?? "interrupt", + } as unknown as QueueSettings; const sessionKey = params?.sessionKey ?? "main"; const followupRun = { prompt: "hello", @@ -147,8 +153,8 @@ function createMinimalRun(params?: { queueKey: "main", resolvedQueue, shouldSteer: false, - shouldFollowup: false, - isActive: false, + shouldFollowup: params?.shouldFollowup ?? false, + isActive: params?.isActive ?? false, isStreaming: false, opts, typing, @@ -274,6 +280,39 @@ async function runReplyAgentWithBase(params: { }); } +describe("runReplyAgent heartbeat followup guard", () => { + it("drops heartbeat runs when another run is active", async () => { + const { run, typing } = createMinimalRun({ + opts: { isHeartbeat: true }, + isActive: true, + shouldFollowup: true, + resolvedQueueMode: "collect", + }); + + const result = await run(); + + expect(result).toBeUndefined(); + expect(vi.mocked(enqueueFollowupRun)).not.toHaveBeenCalled(); + expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(typing.cleanup).toHaveBeenCalledTimes(1); + }); + + it("still enqueues non-heartbeat runs when another run is active", async () => { + const { run } = createMinimalRun({ + opts: { isHeartbeat: false }, + isActive: true, + shouldFollowup: true, + resolvedQueueMode: "collect", + }); + + const result = await run(); + + expect(result).toBeUndefined(); + expect(vi.mocked(enqueueFollowupRun)).toHaveBeenCalledTimes(1); + expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + }); +}); + describe("runReplyAgent typing (heartbeat)", () => { async function withTempStateDir(fn: (stateDir: string) => Promise): Promise { return await withStateDirEnv( diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 33e4c0f7a904..49e7408357e3 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -235,6 +235,11 @@ export async function runReplyAgent(params: { } } + if (isHeartbeat && isActive) { + typing.cleanup(); + return undefined; + } + if (isActive && (shouldFollowup || resolvedQueue.mode === "steer")) { enqueueFollowupRun(queueKey, followupRun, resolvedQueue); await touchActiveSessionEntry(); From eb4a93a8dbdf51918af3198155b8445e2dde1e19 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:59:43 +0000 Subject: [PATCH 348/408] refactor(sandbox): share container-path utils and tighten fs bridge tests --- docs/reference/test.md | 13 +++++++++++++ src/agents/sandbox/fs-bridge.test.ts | 12 +++++++++--- src/agents/sandbox/fs-bridge.ts | 15 ++------------- src/agents/sandbox/fs-paths.ts | 16 ++-------------- src/agents/sandbox/path-utils.ts | 15 +++++++++++++++ 5 files changed, 41 insertions(+), 30 deletions(-) create mode 100644 src/agents/sandbox/path-utils.ts diff --git a/docs/reference/test.md b/docs/reference/test.md index 91db2244bd04..e369b4da7adf 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -15,6 +15,19 @@ title: "Tests" - `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `vmForks` + adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs. - `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip. +## Local PR gate + +For local PR land/gate checks, run: + +- `pnpm check` +- `pnpm build` +- `pnpm test` +- `pnpm check:docs` + +If `pnpm test` flakes on a loaded host, rerun once before treating it as a regression, then isolate with `pnpm vitest run `. For memory-constrained hosts, use: + +- `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` + ## Model latency bench (local keys) Script: [`scripts/bench-model.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/bench-model.ts) diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts index aa3144ca3315..d3bcd735e9ea 100644 --- a/src/agents/sandbox/fs-bridge.test.ts +++ b/src/agents/sandbox/fs-bridge.test.ts @@ -13,13 +13,19 @@ import { createSandboxTestContext } from "./test-fixtures.js"; import type { SandboxContext } from "./types.js"; const mockedExecDockerRaw = vi.mocked(execDockerRaw); +const DOCKER_SCRIPT_INDEX = 5; +const DOCKER_FIRST_SCRIPT_ARG_INDEX = 7; function getDockerScript(args: string[]): string { - return String(args[5] ?? ""); + return String(args[DOCKER_SCRIPT_INDEX] ?? ""); +} + +function getDockerArg(args: string[], position: number): string { + return String(args[DOCKER_FIRST_SCRIPT_ARG_INDEX + position - 1] ?? ""); } function getDockerPathArg(args: string[]): string { - return String(args.at(-1) ?? ""); + return getDockerArg(args, 1); } function getScriptsFromCalls(): string[] { @@ -50,7 +56,7 @@ describe("sandbox fs bridge shell compatibility", () => { const script = getDockerScript(args); if (script.includes('readlink -f -- "$cursor"')) { return { - stdout: Buffer.from(`${String(args.at(-2) ?? "")}\n`), + stdout: Buffer.from(`${getDockerArg(args, 1)}\n`), stderr: Buffer.alloc(0), code: 0, }; diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts index dee44e1b2376..226fc39ca1d4 100644 --- a/src/agents/sandbox/fs-bridge.ts +++ b/src/agents/sandbox/fs-bridge.ts @@ -8,6 +8,7 @@ import { type SandboxResolvedFsPath, type SandboxFsMount, } from "./fs-paths.js"; +import { isPathInsideContainerRoot, normalizeContainerPath } from "./path-utils.js"; import type { SandboxContext, SandboxWorkspaceAccess } from "./types.js"; type RunCommandOptions = { @@ -277,7 +278,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { private resolveMountByContainerPath(containerPath: string): SandboxFsMount | null { const normalized = normalizeContainerPath(containerPath); for (const mount of this.mountsByContainer) { - if (isPathInsidePosix(normalizeContainerPath(mount.containerRoot), normalized)) { + if (isPathInsideContainerRoot(normalizeContainerPath(mount.containerRoot), normalized)) { return mount; } } @@ -351,18 +352,6 @@ function coerceStatType(typeRaw?: string): "file" | "directory" | "other" { return "other"; } -function normalizeContainerPath(value: string): string { - const normalized = path.posix.normalize(value); - return normalized === "." ? "/" : normalized; -} - -function isPathInsidePosix(root: string, target: string): boolean { - if (root === "/") { - return true; - } - return target === root || target.startsWith(`${root}/`); -} - async function assertNoHostSymlinkEscape(params: { absolutePath: string; rootPath: string; diff --git a/src/agents/sandbox/fs-paths.ts b/src/agents/sandbox/fs-paths.ts index 3073c6e84580..7cd239ce0f33 100644 --- a/src/agents/sandbox/fs-paths.ts +++ b/src/agents/sandbox/fs-paths.ts @@ -3,6 +3,7 @@ import { resolveSandboxInputPath, resolveSandboxPath } from "../sandbox-paths.js import { splitSandboxBindSpec } from "./bind-spec.js"; import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; import { resolveSandboxHostPathViaExistingAncestor } from "./host-paths.js"; +import { isPathInsideContainerRoot, normalizeContainerPath } from "./path-utils.js"; import type { SandboxContext } from "./types.js"; export type SandboxFsMount = { @@ -201,7 +202,7 @@ function dedupeMounts(mounts: SandboxFsMount[]): SandboxFsMount[] { function findMountByContainerPath(mounts: SandboxFsMount[], target: string): SandboxFsMount | null { for (const mount of mounts) { - if (isPathInsidePosix(mount.containerRoot, target)) { + if (isPathInsideContainerRoot(mount.containerRoot, target)) { return mount; } } @@ -217,14 +218,6 @@ function findMountByHostPath(mounts: SandboxFsMount[], target: string): SandboxF return null; } -function isPathInsidePosix(root: string, target: string): boolean { - const rel = path.posix.relative(root, target); - if (!rel) { - return true; - } - return !(rel.startsWith("..") || path.posix.isAbsolute(rel)); -} - function isPathInsideHost(root: string, target: string): boolean { const canonicalRoot = resolveSandboxHostPathViaExistingAncestor(path.resolve(root)); const resolvedTarget = path.resolve(target); @@ -259,11 +252,6 @@ function toDisplayRelative(params: { return params.containerPath; } -function normalizeContainerPath(value: string): string { - const normalized = path.posix.normalize(value); - return normalized === "." ? "/" : normalized; -} - function normalizePosixInput(value: string): string { return value.replace(/\\/g, "/").trim(); } diff --git a/src/agents/sandbox/path-utils.ts b/src/agents/sandbox/path-utils.ts new file mode 100644 index 000000000000..7bbc840fef1a --- /dev/null +++ b/src/agents/sandbox/path-utils.ts @@ -0,0 +1,15 @@ +import path from "node:path"; + +export function normalizeContainerPath(value: string): string { + const normalized = path.posix.normalize(value); + return normalized === "." ? "/" : normalized; +} + +export function isPathInsideContainerRoot(root: string, target: string): boolean { + const normalizedRoot = normalizeContainerPath(root); + const normalizedTarget = normalizeContainerPath(target); + if (normalizedRoot === "/") { + return true; + } + return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}/`); +} From c267b5edf6752c0304a0f2bfc4b1d93461f931d6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:01:12 +0000 Subject: [PATCH 349/408] refactor(sandbox): unify tmp alias checks and dedupe hardlink tests --- src/agents/sandbox-paths.test.ts | 129 ++++++++++++++++--------------- src/agents/sandbox-paths.ts | 15 +++- 2 files changed, 79 insertions(+), 65 deletions(-) diff --git a/src/agents/sandbox-paths.test.ts b/src/agents/sandbox-paths.test.ts index e25fd2b3a89f..305da9eb40a7 100644 --- a/src/agents/sandbox-paths.test.ts +++ b/src/agents/sandbox-paths.test.ts @@ -24,6 +24,51 @@ function isPathInside(root: string, target: string): boolean { return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } +function makeTmpProbePath(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`; +} + +async function withOutsideHardlinkInOpenClawTmp( + params: { + openClawTmpDir: string; + hardlinkPrefix: string; + symlinkPrefix?: string; + }, + run: (paths: { hardlinkPath: string; symlinkPath?: string }) => Promise, +): Promise { + const outsideDir = await fs.mkdtemp(path.join(process.cwd(), "sandbox-media-hardlink-outside-")); + const outsideFile = path.join(outsideDir, "outside-secret.txt"); + const hardlinkPath = path.join(params.openClawTmpDir, makeTmpProbePath(params.hardlinkPrefix)); + const symlinkPath = params.symlinkPrefix + ? path.join(params.openClawTmpDir, makeTmpProbePath(params.symlinkPrefix)) + : undefined; + try { + if (isPathInside(params.openClawTmpDir, outsideFile)) { + return; + } + await fs.writeFile(outsideFile, "secret", "utf8"); + await fs.mkdir(params.openClawTmpDir, { recursive: true }); + try { + await fs.link(outsideFile, hardlinkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + if (symlinkPath) { + await fs.symlink(hardlinkPath, symlinkPath); + } + await run({ hardlinkPath, symlinkPath }); + } finally { + if (symlinkPath) { + await fs.rm(symlinkPath, { force: true }); + } + await fs.rm(hardlinkPath, { force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + } +} + describe("resolveSandboxedMediaSource", () => { const openClawTmpDir = resolvePreferredOpenClawTmpDir(); @@ -154,76 +199,38 @@ describe("resolveSandboxedMediaSource", () => { if (process.platform === "win32") { return; } - const outsideDir = await fs.mkdtemp( - path.join(process.cwd(), "sandbox-media-hardlink-outside-"), - ); - const outsideFile = path.join(outsideDir, "outside-secret.txt"); - const hardlinkPath = path.join( - openClawTmpDir, - `sandbox-media-hardlink-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`, + await withOutsideHardlinkInOpenClawTmp( + { + openClawTmpDir, + hardlinkPrefix: "sandbox-media-hardlink", + }, + async ({ hardlinkPath }) => { + await withSandboxRoot(async (sandboxDir) => { + await expectSandboxRejection(hardlinkPath, sandboxDir, /hard.?link|sandbox/i); + }); + }, ); - try { - if (isPathInside(openClawTmpDir, outsideFile)) { - return; - } - await fs.writeFile(outsideFile, "secret", "utf8"); - await fs.mkdir(openClawTmpDir, { recursive: true }); - try { - await fs.link(outsideFile, hardlinkPath); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "EXDEV") { - return; - } - throw err; - } - await withSandboxRoot(async (sandboxDir) => { - await expectSandboxRejection(hardlinkPath, sandboxDir, /hard.?link|sandbox/i); - }); - } finally { - await fs.rm(hardlinkPath, { force: true }); - await fs.rm(outsideDir, { recursive: true, force: true }); - } }); it("rejects symlinked OpenClaw tmp paths to hardlinked outside files", async () => { if (process.platform === "win32") { return; } - const outsideDir = await fs.mkdtemp( - path.join(process.cwd(), "sandbox-media-hardlink-outside-"), - ); - const outsideFile = path.join(outsideDir, "outside-secret.txt"); - const hardlinkPath = path.join( - openClawTmpDir, - `sandbox-media-hardlink-target-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`, - ); - const symlinkPath = path.join( - openClawTmpDir, - `sandbox-media-hardlink-symlink-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`, - ); - try { - if (isPathInside(openClawTmpDir, outsideFile)) { - return; - } - await fs.writeFile(outsideFile, "secret", "utf8"); - await fs.mkdir(openClawTmpDir, { recursive: true }); - try { - await fs.link(outsideFile, hardlinkPath); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "EXDEV") { + await withOutsideHardlinkInOpenClawTmp( + { + openClawTmpDir, + hardlinkPrefix: "sandbox-media-hardlink-target", + symlinkPrefix: "sandbox-media-hardlink-symlink", + }, + async ({ symlinkPath }) => { + if (!symlinkPath) { return; } - throw err; - } - await fs.symlink(hardlinkPath, symlinkPath); - await withSandboxRoot(async (sandboxDir) => { - await expectSandboxRejection(symlinkPath, sandboxDir, /hard.?link|sandbox/i); - }); - } finally { - await fs.rm(symlinkPath, { force: true }); - await fs.rm(hardlinkPath, { force: true }); - await fs.rm(outsideDir, { recursive: true, force: true }); - } + await withSandboxRoot(async (sandboxDir) => { + await expectSandboxRejection(symlinkPath, sandboxDir, /hard.?link|sandbox/i); + }); + }, + ); }); // Group 4: Passthrough diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index 04f0230750c0..761106e85740 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -186,12 +186,19 @@ async function resolveAllowedTmpMediaPath(params: { if (!isPathInside(openClawTmpDir, resolved)) { return undefined; } - await assertNoSymlinkEscape(path.relative(openClawTmpDir, resolved), openClawTmpDir); - await assertNoHardlinkedFinalPath(resolved, openClawTmpDir); + await assertNoTmpAliasEscape({ filePath: resolved, tmpRoot: openClawTmpDir }); return resolved; } -async function assertNoHardlinkedFinalPath(filePath: string, root: string): Promise { +async function assertNoTmpAliasEscape(params: { + filePath: string; + tmpRoot: string; +}): Promise { + await assertNoSymlinkEscape(path.relative(params.tmpRoot, params.filePath), params.tmpRoot); + await assertNoHardlinkedFinalPath(params.filePath, params.tmpRoot); +} + +async function assertNoHardlinkedFinalPath(filePath: string, tmpRoot: string): Promise { let stat: Awaited>; try { stat = await fs.stat(filePath); @@ -206,7 +213,7 @@ async function assertNoHardlinkedFinalPath(filePath: string, root: string): Prom } if (stat.nlink > 1) { throw new Error( - `Hardlinked tmp media path is not allowed under sandbox root (${shortPath(root)}): ${shortPath(filePath)}`, + `Hardlinked tmp media path is not allowed under tmp root (${shortPath(tmpRoot)}): ${shortPath(filePath)}`, ); } } From dcd90438eccaf0e4ac52dcd11732b42c1d5418a4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:01:40 +0000 Subject: [PATCH 350/408] refactor(telegram-tests): split media suites and decouple store mock --- ...s-media-file-path-no-file-download.test.ts | 362 +----------------- src/telegram/bot.media.e2e-harness.ts | 39 +- .../bot.media.stickers-and-fragments.test.ts | 245 ++++++++++++ src/telegram/bot.media.test-utils.ts | 112 ++++++ 4 files changed, 400 insertions(+), 358 deletions(-) create mode 100644 src/telegram/bot.media.stickers-and-fragments.test.ts create mode 100644 src/telegram/bot.media.test-utils.ts diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts index 959b18bb7313..2c02d69d33f6 100644 --- a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts +++ b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts @@ -1,111 +1,12 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import * as ssrf from "../infra/net/ssrf.js"; -import { onSpy, saveMediaBufferSpy, sendChatActionSpy } from "./bot.media.e2e-harness.js"; - -const cacheStickerSpy = vi.fn(); -const getCachedStickerSpy = vi.fn(); -const describeStickerImageSpy = vi.fn(); -const resolvePinnedHostname = ssrf.resolvePinnedHostname; -const lookupMock = vi.fn(); -let resolvePinnedHostnameSpy: ReturnType = null; -const TELEGRAM_TEST_TIMINGS = { - mediaGroupFlushMs: 20, - textFragmentGapMs: 30, -} as const; -const TELEGRAM_BOT_IMPORT_TIMEOUT_MS = process.platform === "win32" ? 180_000 : 150_000; -let createTelegramBot: typeof import("./bot.js").createTelegramBot; -let replySpy: ReturnType; - -async function createBotHandler(): Promise<{ - handler: (ctx: Record) => Promise; - replySpy: ReturnType; - runtimeError: ReturnType; -}> { - return createBotHandlerWithOptions({}); -} - -async function createBotHandlerWithOptions(options: { - proxyFetch?: typeof fetch; - runtimeLog?: ReturnType; - runtimeError?: ReturnType; -}): Promise<{ - handler: (ctx: Record) => Promise; - replySpy: ReturnType; - runtimeError: ReturnType; -}> { - onSpy.mockClear(); - replySpy.mockClear(); - sendChatActionSpy.mockClear(); - - const runtimeError = options.runtimeError ?? vi.fn(); - const runtimeLog = options.runtimeLog ?? vi.fn(); - createTelegramBot({ - token: "tok", - testTimings: TELEGRAM_TEST_TIMINGS, - ...(options.proxyFetch ? { proxyFetch: options.proxyFetch } : {}), - runtime: { - log: runtimeLog as (...data: unknown[]) => void, - error: runtimeError as (...data: unknown[]) => void, - exit: () => { - throw new Error("exit"); - }, - }, - }); - const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as ( - ctx: Record, - ) => Promise; - expect(handler).toBeDefined(); - return { handler, replySpy, runtimeError }; -} - -function mockTelegramFileDownload(params: { - contentType: string; - bytes: Uint8Array; -}): ReturnType { - return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: "OK", - headers: { get: () => params.contentType }, - arrayBuffer: async () => params.bytes.buffer, - } as unknown as Response); -} - -function mockTelegramPngDownload(): ReturnType { - return vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - status: 200, - statusText: "OK", - headers: { get: () => "image/png" }, - arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer, - } as unknown as Response); -} - -beforeEach(() => { - vi.useRealTimers(); - lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); - resolvePinnedHostnameSpy = vi - .spyOn(ssrf, "resolvePinnedHostname") - .mockImplementation((hostname) => resolvePinnedHostname(hostname, lookupMock)); -}); - -afterEach(() => { - lookupMock.mockClear(); - resolvePinnedHostnameSpy?.mockRestore(); - resolvePinnedHostnameSpy = null; -}); - -beforeAll(async () => { - ({ createTelegramBot } = await import("./bot.js")); - const replyModule = await import("../auto-reply/reply.js"); - replySpy = (replyModule as unknown as { __replySpy: ReturnType }).__replySpy; -}, TELEGRAM_BOT_IMPORT_TIMEOUT_MS); - -vi.mock("./sticker-cache.js", () => ({ - cacheSticker: (...args: unknown[]) => cacheStickerSpy(...args), - getCachedSticker: (...args: unknown[]) => getCachedStickerSpy(...args), - describeStickerImage: (...args: unknown[]) => describeStickerImageSpy(...args), -})); +import { afterEach, describe, expect, it, vi } from "vitest"; +import { setNextSavedMediaPath } from "./bot.media.e2e-harness.js"; +import { + TELEGRAM_TEST_TIMINGS, + createBotHandler, + createBotHandlerWithOptions, + mockTelegramFileDownload, + mockTelegramPngDownload, +} from "./bot.media.test-utils.js"; describe("telegram inbound media", () => { // Parallel vitest shards can make this suite slower than the standalone run. @@ -194,8 +95,7 @@ describe("telegram inbound media", () => { bytes: new Uint8Array([0xff, 0xd8, 0xff, 0x00]), }); const inboundPath = "/tmp/media/inbound/file_1095---f00a04a2-99a0-4d98-99b0-dfe61c5a4198.jpg"; - saveMediaBufferSpy.mockResolvedValueOnce({ - id: "media", + setNextSavedMediaPath({ path: inboundPath, size: 4, contentType: "image/jpeg", @@ -483,245 +383,3 @@ describe("telegram forwarded bursts", () => { FORWARD_BURST_TEST_TIMEOUT_MS, ); }); - -describe("telegram stickers", () => { - const STICKER_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000; - - beforeEach(() => { - cacheStickerSpy.mockClear(); - getCachedStickerSpy.mockClear(); - describeStickerImageSpy.mockClear(); - // Re-seed defaults so per-test overrides do not leak when using mockClear. - getCachedStickerSpy.mockReturnValue(undefined); - describeStickerImageSpy.mockReturnValue(undefined); - }); - - it( - "downloads static sticker (WEBP) and includes sticker metadata", - async () => { - const { handler, replySpy, runtimeError } = await createBotHandler(); - const fetchSpy = mockTelegramFileDownload({ - contentType: "image/webp", - bytes: new Uint8Array([0x52, 0x49, 0x46, 0x46]), // RIFF header - }); - - await handler({ - message: { - message_id: 100, - chat: { id: 1234, type: "private" }, - sticker: { - file_id: "sticker_file_id_123", - file_unique_id: "sticker_unique_123", - type: "regular", - width: 512, - height: 512, - is_animated: false, - is_video: false, - emoji: "🎉", - set_name: "TestStickerPack", - }, - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ file_path: "stickers/sticker.webp" }), - }); - - expect(runtimeError).not.toHaveBeenCalled(); - expect(fetchSpy).toHaveBeenCalledWith( - "https://api.telegram.org/file/bottok/stickers/sticker.webp", - expect.objectContaining({ redirect: "manual" }), - ); - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.Body).toContain(""); - expect(payload.Sticker?.emoji).toBe("🎉"); - expect(payload.Sticker?.setName).toBe("TestStickerPack"); - expect(payload.Sticker?.fileId).toBe("sticker_file_id_123"); - - fetchSpy.mockRestore(); - }, - STICKER_TEST_TIMEOUT_MS, - ); - - it( - "refreshes cached sticker metadata on cache hit", - async () => { - const { handler, replySpy, runtimeError } = await createBotHandler(); - - getCachedStickerSpy.mockReturnValue({ - fileId: "old_file_id", - fileUniqueId: "sticker_unique_456", - emoji: "😴", - setName: "OldSet", - description: "Cached description", - cachedAt: "2026-01-20T10:00:00.000Z", - }); - - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: "OK", - headers: { get: () => "image/webp" }, - arrayBuffer: async () => new Uint8Array([0x52, 0x49, 0x46, 0x46]).buffer, - } as unknown as Response); - - await handler({ - message: { - message_id: 103, - chat: { id: 1234, type: "private" }, - sticker: { - file_id: "new_file_id", - file_unique_id: "sticker_unique_456", - type: "regular", - width: 512, - height: 512, - is_animated: false, - is_video: false, - emoji: "🔥", - set_name: "NewSet", - }, - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ file_path: "stickers/sticker.webp" }), - }); - - expect(runtimeError).not.toHaveBeenCalled(); - expect(cacheStickerSpy).toHaveBeenCalledWith( - expect.objectContaining({ - fileId: "new_file_id", - emoji: "🔥", - setName: "NewSet", - }), - ); - const payload = replySpy.mock.calls[0][0]; - expect(payload.Sticker?.fileId).toBe("new_file_id"); - expect(payload.Sticker?.cachedDescription).toBe("Cached description"); - - fetchSpy.mockRestore(); - }, - STICKER_TEST_TIMEOUT_MS, - ); - - it( - "skips animated and video sticker formats that cannot be downloaded", - async () => { - const { handler, replySpy, runtimeError } = await createBotHandler(); - - for (const scenario of [ - { - messageId: 101, - filePath: "stickers/animated.tgs", - sticker: { - file_id: "animated_sticker_id", - file_unique_id: "animated_unique", - type: "regular", - width: 512, - height: 512, - is_animated: true, - is_video: false, - emoji: "😎", - set_name: "AnimatedPack", - }, - }, - { - messageId: 102, - filePath: "stickers/video.webm", - sticker: { - file_id: "video_sticker_id", - file_unique_id: "video_unique", - type: "regular", - width: 512, - height: 512, - is_animated: false, - is_video: true, - emoji: "🎬", - set_name: "VideoPack", - }, - }, - ]) { - replySpy.mockClear(); - runtimeError.mockClear(); - const fetchSpy = vi.spyOn(globalThis, "fetch"); - - await handler({ - message: { - message_id: scenario.messageId, - chat: { id: 1234, type: "private" }, - sticker: scenario.sticker, - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ file_path: scenario.filePath }), - }); - - expect(fetchSpy).not.toHaveBeenCalled(); - expect(replySpy).not.toHaveBeenCalled(); - expect(runtimeError).not.toHaveBeenCalled(); - fetchSpy.mockRestore(); - } - }, - STICKER_TEST_TIMEOUT_MS, - ); -}); - -describe("telegram text fragments", () => { - afterEach(() => { - vi.clearAllTimers(); - }); - - const TEXT_FRAGMENT_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000; - const TEXT_FRAGMENT_FLUSH_MS = TELEGRAM_TEST_TIMINGS.textFragmentGapMs + 80; - - it( - "buffers near-limit text and processes sequential parts as one message", - async () => { - onSpy.mockClear(); - replySpy.mockClear(); - vi.useFakeTimers(); - try { - createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); - const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as ( - ctx: Record, - ) => Promise; - expect(handler).toBeDefined(); - - const part1 = "A".repeat(4050); - const part2 = "B".repeat(50); - - await handler({ - message: { - chat: { id: 42, type: "private" }, - message_id: 10, - date: 1736380800, - text: part1, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({}), - }); - - await handler({ - message: { - chat: { id: 42, type: "private" }, - message_id: 11, - date: 1736380801, - text: part2, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({}), - }); - - expect(replySpy).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(TEXT_FRAGMENT_FLUSH_MS * 2); - expect(replySpy).toHaveBeenCalledTimes(1); - - const payload = replySpy.mock.calls[0][0] as { RawBody?: string; Body?: string }; - expect(payload.RawBody).toContain(part1.slice(0, 32)); - expect(payload.RawBody).toContain(part2.slice(0, 32)); - } finally { - vi.useRealTimers(); - } - }, - TEXT_FRAGMENT_TEST_TIMEOUT_MS, - ); -}); diff --git a/src/telegram/bot.media.e2e-harness.ts b/src/telegram/bot.media.e2e-harness.ts index 191f92744d24..fec64cbdbf0c 100644 --- a/src/telegram/bot.media.e2e-harness.ts +++ b/src/telegram/bot.media.e2e-harness.ts @@ -6,12 +6,38 @@ export const middlewareUseSpy: Mock = vi.fn(); export const onSpy: Mock = vi.fn(); export const stopSpy: Mock = vi.fn(); export const sendChatActionSpy: Mock = vi.fn(); -export const saveMediaBufferSpy: Mock = vi.fn(async (buffer: Buffer, contentType?: string) => ({ - id: "media", - path: "/tmp/telegram-media", - size: buffer.byteLength, - contentType: contentType ?? "application/octet-stream", -})); + +async function defaultSaveMediaBuffer(buffer: Buffer, contentType?: string) { + return { + id: "media", + path: "/tmp/telegram-media", + size: buffer.byteLength, + contentType: contentType ?? "application/octet-stream", + }; +} + +const saveMediaBufferSpy: Mock = vi.fn(defaultSaveMediaBuffer); + +export function setNextSavedMediaPath(params: { + path: string; + id?: string; + contentType?: string; + size?: number; +}) { + saveMediaBufferSpy.mockImplementationOnce( + async (buffer: Buffer, detectedContentType?: string) => ({ + id: params.id ?? "media", + path: params.path, + size: params.size ?? buffer.byteLength, + contentType: params.contentType ?? detectedContentType ?? "application/octet-stream", + }), + ); +} + +export function resetSaveMediaBufferMock() { + saveMediaBufferSpy.mockReset(); + saveMediaBufferSpy.mockImplementation(defaultSaveMediaBuffer); +} type ApiStub = { config: { use: (arg: unknown) => void }; @@ -29,6 +55,7 @@ const apiStub: ApiStub = { beforeEach(() => { resetInboundDedupe(); + resetSaveMediaBufferMock(); }); vi.mock("grammy", () => ({ diff --git a/src/telegram/bot.media.stickers-and-fragments.test.ts b/src/telegram/bot.media.stickers-and-fragments.test.ts new file mode 100644 index 000000000000..fc1b372f778c --- /dev/null +++ b/src/telegram/bot.media.stickers-and-fragments.test.ts @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + TELEGRAM_TEST_TIMINGS, + cacheStickerSpy, + createBotHandler, + createBotHandlerWithOptions, + describeStickerImageSpy, + getCachedStickerSpy, + mockTelegramFileDownload, +} from "./bot.media.test-utils.js"; + +describe("telegram stickers", () => { + const STICKER_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000; + + beforeEach(() => { + cacheStickerSpy.mockClear(); + getCachedStickerSpy.mockClear(); + describeStickerImageSpy.mockClear(); + // Re-seed defaults so per-test overrides do not leak when using mockClear. + getCachedStickerSpy.mockReturnValue(undefined); + describeStickerImageSpy.mockReturnValue(undefined); + }); + + it( + "downloads static sticker (WEBP) and includes sticker metadata", + async () => { + const { handler, replySpy, runtimeError } = await createBotHandler(); + const fetchSpy = mockTelegramFileDownload({ + contentType: "image/webp", + bytes: new Uint8Array([0x52, 0x49, 0x46, 0x46]), // RIFF header + }); + + await handler({ + message: { + message_id: 100, + chat: { id: 1234, type: "private" }, + sticker: { + file_id: "sticker_file_id_123", + file_unique_id: "sticker_unique_123", + type: "regular", + width: 512, + height: 512, + is_animated: false, + is_video: false, + emoji: "🎉", + set_name: "TestStickerPack", + }, + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ file_path: "stickers/sticker.webp" }), + }); + + expect(runtimeError).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledWith( + "https://api.telegram.org/file/bottok/stickers/sticker.webp", + expect.objectContaining({ redirect: "manual" }), + ); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain(""); + expect(payload.Sticker?.emoji).toBe("🎉"); + expect(payload.Sticker?.setName).toBe("TestStickerPack"); + expect(payload.Sticker?.fileId).toBe("sticker_file_id_123"); + + fetchSpy.mockRestore(); + }, + STICKER_TEST_TIMEOUT_MS, + ); + + it( + "refreshes cached sticker metadata on cache hit", + async () => { + const { handler, replySpy, runtimeError } = await createBotHandler(); + + getCachedStickerSpy.mockReturnValue({ + fileId: "old_file_id", + fileUniqueId: "sticker_unique_456", + emoji: "😴", + setName: "OldSet", + description: "Cached description", + cachedAt: "2026-01-20T10:00:00.000Z", + }); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + headers: { get: () => "image/webp" }, + arrayBuffer: async () => new Uint8Array([0x52, 0x49, 0x46, 0x46]).buffer, + } as unknown as Response); + + await handler({ + message: { + message_id: 103, + chat: { id: 1234, type: "private" }, + sticker: { + file_id: "new_file_id", + file_unique_id: "sticker_unique_456", + type: "regular", + width: 512, + height: 512, + is_animated: false, + is_video: false, + emoji: "🔥", + set_name: "NewSet", + }, + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ file_path: "stickers/sticker.webp" }), + }); + + expect(runtimeError).not.toHaveBeenCalled(); + expect(cacheStickerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + fileId: "new_file_id", + emoji: "🔥", + setName: "NewSet", + }), + ); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Sticker?.fileId).toBe("new_file_id"); + expect(payload.Sticker?.cachedDescription).toBe("Cached description"); + + fetchSpy.mockRestore(); + }, + STICKER_TEST_TIMEOUT_MS, + ); + + it( + "skips animated and video sticker formats that cannot be downloaded", + async () => { + const { handler, replySpy, runtimeError } = await createBotHandler(); + + for (const scenario of [ + { + messageId: 101, + filePath: "stickers/animated.tgs", + sticker: { + file_id: "animated_sticker_id", + file_unique_id: "animated_unique", + type: "regular", + width: 512, + height: 512, + is_animated: true, + is_video: false, + emoji: "😎", + set_name: "AnimatedPack", + }, + }, + { + messageId: 102, + filePath: "stickers/video.webm", + sticker: { + file_id: "video_sticker_id", + file_unique_id: "video_unique", + type: "regular", + width: 512, + height: 512, + is_animated: false, + is_video: true, + emoji: "🎬", + set_name: "VideoPack", + }, + }, + ]) { + replySpy.mockClear(); + runtimeError.mockClear(); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await handler({ + message: { + message_id: scenario.messageId, + chat: { id: 1234, type: "private" }, + sticker: scenario.sticker, + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ file_path: scenario.filePath }), + }); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(replySpy).not.toHaveBeenCalled(); + expect(runtimeError).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + } + }, + STICKER_TEST_TIMEOUT_MS, + ); +}); + +describe("telegram text fragments", () => { + afterEach(() => { + vi.clearAllTimers(); + }); + + const TEXT_FRAGMENT_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000; + const TEXT_FRAGMENT_FLUSH_MS = TELEGRAM_TEST_TIMINGS.textFragmentGapMs + 80; + + it( + "buffers near-limit text and processes sequential parts as one message", + async () => { + const { handler, replySpy } = await createBotHandlerWithOptions({}); + vi.useFakeTimers(); + try { + const part1 = "A".repeat(4050); + const part2 = "B".repeat(50); + + await handler({ + message: { + chat: { id: 42, type: "private" }, + message_id: 10, + date: 1736380800, + text: part1, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }); + + await handler({ + message: { + chat: { id: 42, type: "private" }, + message_id: 11, + date: 1736380801, + text: part2, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }); + + expect(replySpy).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(TEXT_FRAGMENT_FLUSH_MS * 2); + expect(replySpy).toHaveBeenCalledTimes(1); + + const payload = replySpy.mock.calls[0][0] as { RawBody?: string }; + expect(payload.RawBody).toContain(part1.slice(0, 32)); + expect(payload.RawBody).toContain(part2.slice(0, 32)); + } finally { + vi.useRealTimers(); + } + }, + TEXT_FRAGMENT_TEST_TIMEOUT_MS, + ); +}); diff --git a/src/telegram/bot.media.test-utils.ts b/src/telegram/bot.media.test-utils.ts new file mode 100644 index 000000000000..4d49eda3f60a --- /dev/null +++ b/src/telegram/bot.media.test-utils.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeAll, beforeEach, expect, vi } from "vitest"; +import * as ssrf from "../infra/net/ssrf.js"; +import { onSpy, sendChatActionSpy } from "./bot.media.e2e-harness.js"; + +export const cacheStickerSpy = vi.fn(); +export const getCachedStickerSpy = vi.fn(); +export const describeStickerImageSpy = vi.fn(); + +const resolvePinnedHostname = ssrf.resolvePinnedHostname; +const lookupMock = vi.fn(); +let resolvePinnedHostnameSpy: ReturnType = null; + +export const TELEGRAM_TEST_TIMINGS = { + mediaGroupFlushMs: 20, + textFragmentGapMs: 30, +} as const; + +const TELEGRAM_BOT_IMPORT_TIMEOUT_MS = process.platform === "win32" ? 180_000 : 150_000; + +let createTelegramBotRef: typeof import("./bot.js").createTelegramBot; +let replySpyRef: ReturnType; + +export async function createBotHandler(): Promise<{ + handler: (ctx: Record) => Promise; + replySpy: ReturnType; + runtimeError: ReturnType; +}> { + return createBotHandlerWithOptions({}); +} + +export async function createBotHandlerWithOptions(options: { + proxyFetch?: typeof fetch; + runtimeLog?: ReturnType; + runtimeError?: ReturnType; +}): Promise<{ + handler: (ctx: Record) => Promise; + replySpy: ReturnType; + runtimeError: ReturnType; +}> { + onSpy.mockClear(); + replySpyRef.mockClear(); + sendChatActionSpy.mockClear(); + + const runtimeError = options.runtimeError ?? vi.fn(); + const runtimeLog = options.runtimeLog ?? vi.fn(); + createTelegramBotRef({ + token: "tok", + testTimings: TELEGRAM_TEST_TIMINGS, + ...(options.proxyFetch ? { proxyFetch: options.proxyFetch } : {}), + runtime: { + log: runtimeLog as (...data: unknown[]) => void, + error: runtimeError as (...data: unknown[]) => void, + exit: () => { + throw new Error("exit"); + }, + }, + }); + const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as ( + ctx: Record, + ) => Promise; + expect(handler).toBeDefined(); + return { handler, replySpy: replySpyRef, runtimeError }; +} + +export function mockTelegramFileDownload(params: { + contentType: string; + bytes: Uint8Array; +}): ReturnType { + return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + headers: { get: () => params.contentType }, + arrayBuffer: async () => params.bytes.buffer, + } as unknown as Response); +} + +export function mockTelegramPngDownload(): ReturnType { + return vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: { get: () => "image/png" }, + arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer, + } as unknown as Response); +} + +beforeEach(() => { + vi.useRealTimers(); + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + resolvePinnedHostnameSpy = vi + .spyOn(ssrf, "resolvePinnedHostname") + .mockImplementation((hostname) => resolvePinnedHostname(hostname, lookupMock)); +}); + +afterEach(() => { + lookupMock.mockClear(); + resolvePinnedHostnameSpy?.mockRestore(); + resolvePinnedHostnameSpy = null; +}); + +beforeAll(async () => { + ({ createTelegramBot: createTelegramBotRef } = await import("./bot.js")); + const replyModule = await import("../auto-reply/reply.js"); + replySpyRef = (replyModule as unknown as { __replySpy: ReturnType }).__replySpy; +}, TELEGRAM_BOT_IMPORT_TIMEOUT_MS); + +vi.mock("./sticker-cache.js", () => ({ + cacheSticker: (...args: unknown[]) => cacheStickerSpy(...args), + getCachedSticker: (...args: unknown[]) => getCachedStickerSpy(...args), + describeStickerImage: (...args: unknown[]) => describeStickerImageSpy(...args), +})); From e0201c2774b8465b3bde85a097196a7241b51ab1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:03:15 +0000 Subject: [PATCH 351/408] fix: keep channel typing active during long inference (#25886, thanks @stakeswky) Co-authored-by: stakeswky --- CHANGELOG.md | 1 + .../matrix/src/matrix/monitor/handler.ts | 1 + .../mattermost/src/mattermost/monitor.ts | 2 + extensions/msteams/src/reply-dispatcher.ts | 2 + src/channels/channel-helpers.test.ts | 44 ++++++++++++++++++ src/channels/typing.ts | 46 +++++++++++++++++-- .../monitor/message-handler.process.ts | 2 + src/signal/monitor/event-handler.ts | 2 + src/slack/monitor/message-handler/dispatch.ts | 1 + src/telegram/bot-message-dispatch.ts | 26 ++++++----- 10 files changed, 111 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e93bc017670..7601b9997b2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. - Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from `last` to `none` (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851) - Auto-reply/Heartbeat queueing: drop heartbeat runs when a session already has an active run instead of enqueueing a stale followup, preventing duplicate heartbeat response branches after queue drain. (#25610, #25606) Thanks @mcaxtr. +- Channels/Typing keepalive: refresh channel typing callbacks on a keepalive interval during long replies and clear keepalive timers on idle/cleanup across core + extension dispatcher callsites so typing indicators do not expire mid-inference. (#25886, #25882) Thanks @stakeswky. - Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting. - Security/Sandbox media: reject hard-linked OpenClaw tmp media aliases (including symlink-to-hardlink chains) during sandbox media path resolution to prevent out-of-sandbox inode alias reads. (#25820) Thanks @bmendonca3. - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index c1df46fec2ad..f62ef60ce3b7 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -654,6 +654,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }, onReplyStart: typingCallbacks.onReplyStart, onIdle: typingCallbacks.onIdle, + onCleanup: typingCallbacks.onCleanup, }); const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index fe799a295c93..233aee17d1b8 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -805,6 +805,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`); }, onReplyStart: typingCallbacks.onReplyStart, + onIdle: typingCallbacks.onIdle, + onCleanup: typingCallbacks.onCleanup, }); await core.channel.reply.dispatchReplyFromConfig({ diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 55389f2f6960..755ae3e56e12 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -122,6 +122,8 @@ export function createMSTeamsReplyDispatcher(params: { }); }, onReplyStart: typingCallbacks.onReplyStart, + onIdle: typingCallbacks.onIdle, + onCleanup: typingCallbacks.onCleanup, }); return { diff --git a/src/channels/channel-helpers.test.ts b/src/channels/channel-helpers.test.ts index b6d3ff4fbd8b..34bd5370526c 100644 --- a/src/channels/channel-helpers.test.ts +++ b/src/channels/channel-helpers.test.ts @@ -190,4 +190,48 @@ describe("createTypingCallbacks", () => { expect(stop).toHaveBeenCalledTimes(1); expect(onStopError).toHaveBeenCalledTimes(1); }); + + it("sends typing keepalive pings until idle cleanup", async () => { + vi.useFakeTimers(); + try { + const start = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockResolvedValue(undefined); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, stop, onStartError }); + + await callbacks.onReplyStart(); + expect(start).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(2_999); + expect(start).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + expect(start).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(3_000); + expect(start).toHaveBeenCalledTimes(3); + + callbacks.onIdle?.(); + await flushMicrotasks(); + expect(stop).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(9_000); + expect(start).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + + it("deduplicates stop across idle and cleanup", async () => { + const start = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockResolvedValue(undefined); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, stop, onStartError }); + + callbacks.onIdle?.(); + callbacks.onCleanup?.(); + await flushMicrotasks(); + + expect(stop).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/channels/typing.ts b/src/channels/typing.ts index 6ab2a975361c..f6d60d498d1e 100644 --- a/src/channels/typing.ts +++ b/src/channels/typing.ts @@ -10,9 +10,15 @@ export function createTypingCallbacks(params: { stop?: () => Promise; onStartError: (err: unknown) => void; onStopError?: (err: unknown) => void; + keepaliveIntervalMs?: number; }): TypingCallbacks { const stop = params.stop; - const onReplyStart = async () => { + const keepaliveIntervalMs = params.keepaliveIntervalMs ?? 3_000; + let keepaliveTimer: ReturnType | undefined; + let keepaliveStartInFlight = false; + let stopSent = false; + + const fireStart = async () => { try { await params.start(); } catch (err) { @@ -20,11 +26,41 @@ export function createTypingCallbacks(params: { } }; - const fireStop = stop - ? () => { - void stop().catch((err) => (params.onStopError ?? params.onStartError)(err)); + const clearKeepalive = () => { + if (!keepaliveTimer) { + return; + } + clearInterval(keepaliveTimer); + keepaliveTimer = undefined; + keepaliveStartInFlight = false; + }; + + const onReplyStart = async () => { + stopSent = false; + clearKeepalive(); + await fireStart(); + if (keepaliveIntervalMs <= 0) { + return; + } + keepaliveTimer = setInterval(() => { + if (keepaliveStartInFlight) { + return; } - : undefined; + keepaliveStartInFlight = true; + void fireStart().finally(() => { + keepaliveStartInFlight = false; + }); + }, keepaliveIntervalMs); + }; + + const fireStop = () => { + clearKeepalive(); + if (!stop || stopSent) { + return; + } + stopSent = true; + void stop().catch((err) => (params.onStopError ?? params.onStartError)(err)); + }; return { onReplyStart, onIdle: fireStop, onCleanup: fireStop }; } diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 59b0ceaf6494..1d84cd01410c 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -669,6 +669,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) await typingCallbacks.onReplyStart(); await statusReactions.setThinking(); }, + onIdle: typingCallbacks.onIdle, + onCleanup: typingCallbacks.onCleanup, }); let dispatchResult: Awaited> | null = null; diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 8454de9d5259..4133930389a1 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -238,6 +238,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`)); }, onReplyStart: typingCallbacks.onReplyStart, + onIdle: typingCallbacks.onIdle, + onCleanup: typingCallbacks.onCleanup, }); const { queuedFinal } = await dispatchInboundMessage({ diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index d726f804c106..f6d4b531f61b 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -306,6 +306,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }, onReplyStart: typingCallbacks.onReplyStart, onIdle: typingCallbacks.onIdle, + onCleanup: typingCallbacks.onCleanup, }); const draftStream = createSlackDraftStream({ diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 7dd0c48450ac..89cb59038db3 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -418,6 +418,18 @@ export const dispatchTelegramMessage = async ({ void statusReactionController.setThinking(); } + const typingCallbacks = createTypingCallbacks({ + start: sendTyping, + onStartError: (err) => { + logTypingFailure({ + log: logVerbose, + channel: "telegram", + target: String(chatId), + error: err, + }); + }, + }); + try { ({ queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, @@ -528,17 +540,9 @@ export const dispatchTelegramMessage = async ({ deliveryState.markNonSilentFailure(); runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); }, - onReplyStart: createTypingCallbacks({ - start: sendTyping, - onStartError: (err) => { - logTypingFailure({ - log: logVerbose, - channel: "telegram", - target: String(chatId), - error: err, - }); - }, - }).onReplyStart, + onReplyStart: typingCallbacks.onReplyStart, + onIdle: typingCallbacks.onIdle, + onCleanup: typingCallbacks.onCleanup, }, replyOptions: { skillFilter, From a805d6b4393dc2f51d8fa6d73f6e2fbb3a50960e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:02:26 +0000 Subject: [PATCH 352/408] fix(heartbeat): block dm targets and internalize blocked prompts --- CHANGELOG.md | 2 + docs/gateway/configuration-reference.md | 1 + docs/gateway/configuration.md | 2 +- docs/gateway/heartbeat.md | 2 + docs/gateway/troubleshooting.md | 1 + docs/start/openclaw.md | 1 + .../heartbeat-runner.ghost-reminder.test.ts | 44 +++++++++++- src/infra/outbound/targets.test.ts | 44 +++++++++++- src/infra/outbound/targets.ts | 70 +++++++++++++++++++ 9 files changed, 161 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7601b9997b2b..f01eed45ffa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,11 @@ Docs: https://docs.openclaw.ai ### Breaking - **BREAKING:** Security/Sandbox: block Docker `network: "container:"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting. +- **BREAKING:** Heartbeat delivery now blocks DM-style `user:` targets. Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages. ### Fixes +- Heartbeat routing: prevent heartbeat leakage/spam into Discord DMs by blocking DM-style heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871) - iMessage/Reasoning safety: harden iMessage echo suppression with outbound `messageId` matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb. - Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 `dev=0` stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false `Local media path is not safe to read` drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng. - Windows/Exec shell selection: prefer PowerShell 7 (`pwsh`) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing `&&` command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 58c1d6fd504a..050106968f44 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -812,6 +812,7 @@ Periodic heartbeat runs. - `every`: duration string (ms/s/m/h). Default: `30m`. - `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs. +- Heartbeats never deliver to DM-style `user:` targets; those runs still execute, but outbound delivery is skipped. - Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats. - Heartbeats run full agent turns — shorter intervals burn more tokens. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index f4fea3b5a35b..3f7403d4647e 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -239,7 +239,7 @@ When validation fails: ``` - `every`: duration string (`30m`, `2h`). Set `0m` to disable. - - `target`: `last` | `whatsapp` | `telegram` | `discord` | `none` + - `target`: `last` | `whatsapp` | `telegram` | `discord` | `none` (DM-style `user:` heartbeat delivery is blocked) - See [Heartbeat](/gateway/heartbeat) for the full guide. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index e22d09906127..c2a762bb6a02 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -215,6 +215,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `last`: deliver to the last used external channel. - explicit channel: `whatsapp` / `telegram` / `discord` / `googlechat` / `slack` / `msteams` / `signal` / `imessage`. - `none` (default): run the heartbeat but **do not deliver** externally. +- DM-style heartbeat destinations are blocked (`user:` targets resolve to no-delivery). - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). For Telegram topics/threads, use `:topic:`. - `accountId`: optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped. - `prompt`: overrides the default prompt body (not merged). @@ -235,6 +236,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `session` only affects the run context; delivery is controlled by `target` and `to`. - To deliver to a specific channel/recipient, set `target` + `to`. With `target: "last"`, delivery uses the last external channel for that session. +- Heartbeat deliveries never send to DM-style `user:` targets; those runs still execute, but outbound delivery is skipped. - If the main queue is busy, the heartbeat is skipped and retried later. - If `target` resolves to no external destination, the run still happens but no outbound message is sent. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index d3bb0ad9e410..69bf0c450d7b 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -174,6 +174,7 @@ Common signatures: - `cron: timer tick failed` → scheduler tick failed; check file/log/runtime errors. - `heartbeat skipped` with `reason=quiet-hours` → outside active hours window. - `heartbeat: unknown accountId` → invalid account id for heartbeat delivery target. +- `heartbeat skipped` with `reason=dm-blocked` → heartbeat target resolved to a DM-style `user:` destination (blocked by design). Related: diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md index fec776bb8f6a..058f2fa67fef 100644 --- a/docs/start/openclaw.md +++ b/docs/start/openclaw.md @@ -164,6 +164,7 @@ Set `agents.defaults.heartbeat.every: "0m"` to disable. - If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls. - If the file is missing, the heartbeat still runs and the model decides what to do. - If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), OpenClaw suppresses outbound delivery for that heartbeat. +- Heartbeat delivery to DM-style `user:` targets is blocked; those runs still execute but skip outbound delivery. - Heartbeats run full agent turns — shorter intervals burn more tokens. ```json5 diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index b835df8863d9..2696d4bdb032 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -37,6 +37,7 @@ describe("Ghost reminder bug (issue #13317)", () => { const createConfig = async (params: { tmpDir: string; storePath: string; + target?: "telegram" | "none"; }): Promise<{ cfg: OpenClawConfig; sessionKey: string }> => { const cfg: OpenClawConfig = { agents: { @@ -44,7 +45,7 @@ describe("Ghost reminder bug (issue #13317)", () => { workspace: params.tmpDir, heartbeat: { every: "5m", - target: "telegram", + target: params.target ?? "telegram", }, }, }, @@ -96,6 +97,7 @@ describe("Ghost reminder bug (issue #13317)", () => { replyText: string; reason: string; enqueue: (sessionKey: string) => void; + target?: "telegram" | "none"; }): Promise<{ result: Awaited>; sendTelegram: ReturnType; @@ -105,7 +107,11 @@ describe("Ghost reminder bug (issue #13317)", () => { return withTempHeartbeatSandbox( async ({ tmpDir, storePath }) => { const { sendTelegram, getReplySpy } = createHeartbeatDeps(params.replyText); - const { cfg, sessionKey } = await createConfig({ tmpDir, storePath }); + const { cfg, sessionKey } = await createConfig({ + tmpDir, + storePath, + target: params.target, + }); params.enqueue(sessionKey); const result = await runHeartbeatOnce({ cfg, @@ -192,4 +198,38 @@ describe("Ghost reminder bug (issue #13317)", () => { expect(calledCtx?.Body).not.toContain("Read HEARTBEAT.md"); expect(sendTelegram).toHaveBeenCalled(); }); + + it("uses an internal-only cron prompt when delivery target is none", async () => { + const { result, sendTelegram, calledCtx } = await runHeartbeatCase({ + tmpPrefix: "openclaw-cron-internal-", + replyText: "Handled internally", + reason: "cron:reminder-job", + target: "none", + enqueue: (sessionKey) => { + enqueueSystemEvent("Reminder: Rotate API keys", { sessionKey }); + }, + }); + + expect(result.status).toBe("ran"); + expect(calledCtx?.Provider).toBe("cron-event"); + expect(calledCtx?.Body).toContain("Handle this reminder internally"); + expect(sendTelegram).not.toHaveBeenCalled(); + }); + + it("uses an internal-only exec prompt when delivery target is none", async () => { + const { result, sendTelegram, calledCtx } = await runHeartbeatCase({ + tmpPrefix: "openclaw-exec-internal-", + replyText: "Handled internally", + reason: "exec-event", + target: "none", + enqueue: (sessionKey) => { + enqueueSystemEvent("exec finished: deploy succeeded", { sessionKey }); + }, + }); + + expect(result.status).toBe("ran"); + expect(calledCtx?.Provider).toBe("exec-event"); + expect(calledCtx?.Body).toContain("Handle the result internally"); + expect(sendTelegram).not.toHaveBeenCalled(); + }); }); diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 52d820437565..be698c3c5ef3 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -301,7 +301,7 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.to).toBe("63448508"); }); - it("does not return inherited threadId from resolveHeartbeatDeliveryTarget", () => { + it("blocks heartbeat delivery to Slack DMs and avoids inherited threadId", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, @@ -317,11 +317,49 @@ describe("resolveSessionDeliveryTarget", () => { }, }); - expect(resolved.channel).toBe("slack"); - expect(resolved.to).toBe("user:U123"); + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); expect(resolved.threadId).toBeUndefined(); }); + it("blocks heartbeat delivery to Discord DMs", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-discord-dm", + updatedAt: 1, + lastChannel: "discord", + lastTo: "user:12345", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); + }); + + it("keeps heartbeat delivery to Discord channels", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-discord-channel", + updatedAt: 1, + lastChannel: "discord", + lastTo: "channel:999", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("discord"); + expect(resolved.to).toBe("channel:999"); + }); + it("keeps explicit threadId in heartbeat mode", () => { const resolved = resolveSessionDeliveryTarget({ entry: { diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 6df0ecee6d20..9f1770f88fe5 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -1,10 +1,13 @@ +import type { ChatType } from "../../channels/chat-type.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js"; import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; +import { parseDiscordTarget } from "../../discord/targets.js"; import { normalizeAccountId } from "../../routing/session-key.js"; +import { parseSlackTarget } from "../../slack/targets.js"; import { parseTelegramTarget } from "../../telegram/targets.js"; import { deliveryContextFromSession } from "../../utils/delivery-context.js"; import type { @@ -319,6 +322,20 @@ export function resolveHeartbeatDeliveryTarget(params: { }; } + const deliveryChatType = resolveHeartbeatDeliveryChatType({ + channel: resolvedTarget.channel, + to: resolved.to, + }); + if (deliveryChatType === "direct") { + return { + channel: "none", + reason: "dm-blocked", + accountId: effectiveAccountId, + lastChannel: resolvedTarget.lastChannel, + lastAccountId: resolvedTarget.lastAccountId, + }; + } + let reason: string | undefined; const plugin = getChannelPlugin(resolvedTarget.channel); if (plugin?.config.resolveAllowFrom) { @@ -345,6 +362,59 @@ export function resolveHeartbeatDeliveryTarget(params: { }; } +function inferChatTypeFromTarget(params: { + channel: DeliverableMessageChannel; + to: string; +}): ChatType | undefined { + const to = params.to.trim(); + if (!to) { + return undefined; + } + + if (/^user:/i.test(to)) { + return "direct"; + } + if (/^(channel:|thread:)/i.test(to)) { + return "channel"; + } + if (/^group:/i.test(to)) { + return "group"; + } + + switch (params.channel) { + case "discord": { + try { + const target = parseDiscordTarget(to, { defaultKind: "channel" }); + if (!target) { + return undefined; + } + return target.kind === "user" ? "direct" : "channel"; + } catch { + return undefined; + } + } + case "slack": { + const target = parseSlackTarget(to, { defaultKind: "channel" }); + if (!target) { + return undefined; + } + return target.kind === "user" ? "direct" : "channel"; + } + default: + return undefined; + } +} + +function resolveHeartbeatDeliveryChatType(params: { + channel: DeliverableMessageChannel; + to: string; +}): ChatType | undefined { + return inferChatTypeFromTarget({ + channel: params.channel, + to: params.to, + }); +} + function resolveHeartbeatSenderId(params: { allowFrom: Array; deliveryTo?: string; From e28803503d9e01c6f3d5e05025f5e3ad9399f40b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:09:22 +0000 Subject: [PATCH 353/408] fix: add sandbox bind-override regression coverage (#25410) (thanks @skyer-jian) --- CHANGELOG.md | 1 + src/config/config.sandbox-docker.test.ts | 41 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f01eed45ffa8..ea31fe236f3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai - Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall. - Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility. - Sandbox/FS bridge tests: add regression coverage for dash-leading basenames to confirm sandbox file reads resolve to absolute container paths (and avoid shell-option misdiagnosis for dashed filenames). (#25891) Thanks @albertlieyingadrian. +- Sandbox/Config: preserve `dangerouslyAllowReservedContainerTargets` and `dangerouslyAllowExternalBindSources` during sandbox docker config resolution so explicit bind-mount break-glass overrides reach runtime validation. (#25410) Thanks @skyer-jian. - Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. - Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch. - Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. diff --git a/src/config/config.sandbox-docker.test.ts b/src/config/config.sandbox-docker.test.ts index 71b24af01ef9..1124eca5fbe1 100644 --- a/src/config/config.sandbox-docker.test.ts +++ b/src/config/config.sandbox-docker.test.ts @@ -103,6 +103,47 @@ describe("sandbox docker config", () => { expect(overridden.dangerouslyAllowContainerNamespaceJoin).toBe(false); }); + it("uses agent override precedence for bind-mount dangerous overrides", () => { + const inherited = resolveSandboxDockerConfig({ + scope: "agent", + globalDocker: { + dangerouslyAllowReservedContainerTargets: true, + dangerouslyAllowExternalBindSources: true, + }, + agentDocker: {}, + }); + expect(inherited.dangerouslyAllowReservedContainerTargets).toBe(true); + expect(inherited.dangerouslyAllowExternalBindSources).toBe(true); + + const overridden = resolveSandboxDockerConfig({ + scope: "agent", + globalDocker: { + dangerouslyAllowReservedContainerTargets: true, + dangerouslyAllowExternalBindSources: true, + }, + agentDocker: { + dangerouslyAllowReservedContainerTargets: false, + dangerouslyAllowExternalBindSources: false, + }, + }); + expect(overridden.dangerouslyAllowReservedContainerTargets).toBe(false); + expect(overridden.dangerouslyAllowExternalBindSources).toBe(false); + + const sharedScope = resolveSandboxDockerConfig({ + scope: "shared", + globalDocker: { + dangerouslyAllowReservedContainerTargets: true, + dangerouslyAllowExternalBindSources: true, + }, + agentDocker: { + dangerouslyAllowReservedContainerTargets: false, + dangerouslyAllowExternalBindSources: false, + }, + }); + expect(sharedScope.dangerouslyAllowReservedContainerTargets).toBe(true); + expect(sharedScope.dangerouslyAllowExternalBindSources).toBe(true); + }); + it("rejects seccomp unconfined via Zod schema validation", () => { const res = validateConfigObject({ agents: { From 885452f5c1611c3f255ffb89e55e7fc00e8c9761 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:41:04 +0000 Subject: [PATCH 354/408] fix: fail-closed shared-session reply routing (#24571) (thanks @brandonwise) --- CHANGELOG.md | 1 + src/commands/agent.delivery.test.ts | 43 +++++++++++++++++ src/commands/agent/delivery.ts | 8 ++++ src/gateway/server-methods/agent.ts | 14 ++++++ src/infra/outbound/agent-delivery.test.ts | 37 +++++++++++++++ src/infra/outbound/agent-delivery.ts | 15 ++++-- src/infra/outbound/targets.test.ts | 58 +++++++++++++++++++++++ src/infra/outbound/targets.ts | 18 +++---- 8 files changed, 180 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea31fe236f3c..3d3b33609915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Sandbox/FS bridge tests: add regression coverage for dash-leading basenames to confirm sandbox file reads resolve to absolute container paths (and avoid shell-option misdiagnosis for dashed filenames). (#25891) Thanks @albertlieyingadrian. - Sandbox/Config: preserve `dangerouslyAllowReservedContainerTargets` and `dangerouslyAllowExternalBindSources` during sandbox docker config resolution so explicit bind-mount break-glass overrides reach runtime validation. (#25410) Thanks @skyer-jian. - Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. +- Security/Routing: fail closed for shared-session cross-channel replies by binding outbound target resolution to the current turn’s source channel metadata (instead of stale session route fallbacks), and wire those turn-source fields through gateway + command delivery planners with regression coverage. (#24571) Thanks @brandonwise. - Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch. - Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. - Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from `last` to `none` (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851) diff --git a/src/commands/agent.delivery.test.ts b/src/commands/agent.delivery.test.ts index 7d9867cbaf37..baa44213ab46 100644 --- a/src/commands/agent.delivery.test.ts +++ b/src/commands/agent.delivery.test.ts @@ -191,6 +191,49 @@ describe("deliverAgentCommandResult", () => { ); }); + it("uses runContext turn source over stale session last route", async () => { + await runDelivery({ + opts: { + message: "hello", + deliver: true, + runContext: { + messageChannel: "whatsapp", + currentChannelId: "+15559876543", + accountId: "work", + }, + }, + sessionEntry: { + lastChannel: "slack", + lastTo: "U_WRONG", + lastAccountId: "wrong", + } as SessionEntry, + }); + + expect(mocks.resolveOutboundTarget).toHaveBeenCalledWith( + expect.objectContaining({ channel: "whatsapp", to: "+15559876543", accountId: "work" }), + ); + }); + + it("does not reuse session lastTo when runContext source omits currentChannelId", async () => { + await runDelivery({ + opts: { + message: "hello", + deliver: true, + runContext: { + messageChannel: "whatsapp", + }, + }, + sessionEntry: { + lastChannel: "slack", + lastTo: "U_WRONG", + } as SessionEntry, + }); + + expect(mocks.resolveOutboundTarget).toHaveBeenCalledWith( + expect.objectContaining({ channel: "whatsapp", to: undefined }), + ); + }); + it("prefixes nested agent outputs with context", async () => { const runtime = createRuntime(); await runDelivery({ diff --git a/src/commands/agent/delivery.ts b/src/commands/agent/delivery.ts index 24ef360a5862..caecb2a62836 100644 --- a/src/commands/agent/delivery.ts +++ b/src/commands/agent/delivery.ts @@ -71,6 +71,10 @@ export async function deliverAgentCommandResult(params: { const { cfg, deps, runtime, opts, sessionEntry, payloads, result } = params; const deliver = opts.deliver === true; const bestEffortDeliver = opts.bestEffortDeliver === true; + const turnSourceChannel = opts.runContext?.messageChannel ?? opts.messageChannel; + const turnSourceTo = opts.runContext?.currentChannelId ?? opts.to; + const turnSourceAccountId = opts.runContext?.accountId ?? opts.accountId; + const turnSourceThreadId = opts.runContext?.currentThreadTs ?? opts.threadId; const deliveryPlan = resolveAgentDeliveryPlan({ sessionEntry, requestedChannel: opts.replyChannel ?? opts.channel, @@ -78,6 +82,10 @@ export async function deliverAgentCommandResult(params: { explicitThreadId: opts.threadId, accountId: opts.replyAccountId ?? opts.accountId, wantsDelivery: deliver, + turnSourceChannel, + turnSourceTo, + turnSourceAccountId, + turnSourceThreadId, }); let deliveryChannel = deliveryPlan.resolvedChannel; const explicitChannelHint = (opts.replyChannel ?? opts.channel)?.trim(); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index b24691d82835..387077a8bbd5 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -487,6 +487,16 @@ export const agentHandlers: GatewayRequestHandlers = { typeof request.threadId === "string" && request.threadId.trim() ? request.threadId.trim() : undefined; + const turnSourceChannel = + typeof request.channel === "string" && request.channel.trim() + ? request.channel.trim() + : undefined; + const turnSourceTo = + typeof request.to === "string" && request.to.trim() ? request.to.trim() : undefined; + const turnSourceAccountId = + typeof request.accountId === "string" && request.accountId.trim() + ? request.accountId.trim() + : undefined; const deliveryPlan = resolveAgentDeliveryPlan({ sessionEntry, requestedChannel: request.replyChannel ?? request.channel, @@ -494,6 +504,10 @@ export const agentHandlers: GatewayRequestHandlers = { explicitThreadId, accountId: request.replyAccountId ?? request.accountId, wantsDelivery, + turnSourceChannel, + turnSourceTo, + turnSourceAccountId, + turnSourceThreadId: explicitThreadId, }); let resolvedChannel = deliveryPlan.resolvedChannel; diff --git a/src/infra/outbound/agent-delivery.test.ts b/src/infra/outbound/agent-delivery.test.ts index 6a1ae858d7bc..b137ce2a73ff 100644 --- a/src/infra/outbound/agent-delivery.test.ts +++ b/src/infra/outbound/agent-delivery.test.ts @@ -96,4 +96,41 @@ describe("agent delivery helpers", () => { expect(mocks.resolveOutboundTarget).not.toHaveBeenCalled(); expect(resolved.resolvedTo).toBe("+1555"); }); + + it("prefers turn-source delivery context over session last route", () => { + const plan = resolveAgentDeliveryPlan({ + sessionEntry: { + sessionId: "s4", + updatedAt: 4, + deliveryContext: { channel: "slack", to: "U_WRONG", accountId: "wrong" }, + }, + requestedChannel: "last", + turnSourceChannel: "whatsapp", + turnSourceTo: "+17775550123", + turnSourceAccountId: "work", + accountId: undefined, + wantsDelivery: true, + }); + + expect(plan.resolvedChannel).toBe("whatsapp"); + expect(plan.resolvedTo).toBe("+17775550123"); + expect(plan.resolvedAccountId).toBe("work"); + }); + + it("does not reuse mutable session to when only turnSourceChannel is provided", () => { + const plan = resolveAgentDeliveryPlan({ + sessionEntry: { + sessionId: "s5", + updatedAt: 5, + deliveryContext: { channel: "slack", to: "U_WRONG" }, + }, + requestedChannel: "last", + turnSourceChannel: "whatsapp", + accountId: undefined, + wantsDelivery: true, + }); + + expect(plan.resolvedChannel).toBe("whatsapp"); + expect(plan.resolvedTo).toBeUndefined(); + }); }); diff --git a/src/infra/outbound/agent-delivery.ts b/src/infra/outbound/agent-delivery.ts index 2600a076014d..1eedcb695680 100644 --- a/src/infra/outbound/agent-delivery.ts +++ b/src/infra/outbound/agent-delivery.ts @@ -65,6 +65,15 @@ export function resolveAgentDeliveryPlan(params: { normalizedTurnSource && isDeliverableMessageChannel(normalizedTurnSource) ? normalizedTurnSource : undefined; + const turnSourceTo = + typeof params.turnSourceTo === "string" && params.turnSourceTo.trim() + ? params.turnSourceTo.trim() + : undefined; + const turnSourceAccountId = normalizeAccountId(params.turnSourceAccountId); + const turnSourceThreadId = + params.turnSourceThreadId != null && params.turnSourceThreadId !== "" + ? params.turnSourceThreadId + : undefined; const baseDelivery = resolveSessionDeliveryTarget({ entry: params.sessionEntry, @@ -72,9 +81,9 @@ export function resolveAgentDeliveryPlan(params: { explicitTo, explicitThreadId: params.explicitThreadId, turnSourceChannel, - turnSourceTo: params.turnSourceTo, - turnSourceAccountId: params.turnSourceAccountId, - turnSourceThreadId: params.turnSourceThreadId, + turnSourceTo, + turnSourceAccountId, + turnSourceThreadId, }); const resolvedChannel = (() => { diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index be698c3c5ef3..24b7343e9bff 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -470,4 +470,62 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", expect(resolved.accountId).toBe("bot-123"); expect(resolved.threadId).toBe(42); }); + + it("does not fall back to session target metadata when turnSourceChannel is set", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-no-fallback", + updatedAt: 1, + lastChannel: "slack", + lastTo: "U_WRONG", + lastAccountId: "wrong-account", + lastThreadId: "1739142736.000100", + }, + requestedChannel: "last", + turnSourceChannel: "whatsapp", + }); + + expect(resolved.channel).toBe("whatsapp"); + expect(resolved.to).toBeUndefined(); + expect(resolved.accountId).toBeUndefined(); + expect(resolved.threadId).toBeUndefined(); + expect(resolved.lastTo).toBeUndefined(); + expect(resolved.lastAccountId).toBeUndefined(); + expect(resolved.lastThreadId).toBeUndefined(); + }); + + it("uses explicitTo even when turnSourceTo is omitted", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-explicit-to", + updatedAt: 1, + lastChannel: "slack", + lastTo: "U_WRONG", + }, + requestedChannel: "last", + explicitTo: "+15551234567", + turnSourceChannel: "whatsapp", + }); + + expect(resolved.channel).toBe("whatsapp"); + expect(resolved.to).toBe("+15551234567"); + }); + + it("still allows mismatched lastTo only from turn-scoped metadata", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-mismatch-turn", + updatedAt: 1, + lastChannel: "slack", + lastTo: "U_WRONG", + }, + requestedChannel: "telegram", + allowMismatchedLastTo: true, + turnSourceChannel: "whatsapp", + turnSourceTo: "+15550000000", + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("+15550000000"); + }); }); diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 9f1770f88fe5..cf08ac74db85 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -89,17 +89,13 @@ export function resolveSessionDeliveryTarget(params: { const sessionLastChannel = context?.channel && isDeliverableMessageChannel(context.channel) ? context.channel : undefined; - // When a turn-source channel is provided, use it instead of the session's - // mutable lastChannel. This prevents a concurrent inbound from a different - // channel from hijacking the reply target (cross-channel privacy leak). - const lastChannel = params.turnSourceChannel ?? sessionLastChannel; - const lastTo = params.turnSourceChannel ? (params.turnSourceTo ?? context?.to) : context?.to; - const lastAccountId = params.turnSourceChannel - ? (params.turnSourceAccountId ?? context?.accountId) - : context?.accountId; - const lastThreadId = params.turnSourceChannel - ? (params.turnSourceThreadId ?? context?.threadId) - : context?.threadId; + // When a turn-source channel is provided, use only turn-scoped metadata. + // Falling back to mutable session fields would re-introduce routing races. + const hasTurnSourceChannel = params.turnSourceChannel != null; + const lastChannel = hasTurnSourceChannel ? params.turnSourceChannel : sessionLastChannel; + const lastTo = hasTurnSourceChannel ? params.turnSourceTo : context?.to; + const lastAccountId = hasTurnSourceChannel ? params.turnSourceAccountId : context?.accountId; + const lastThreadId = hasTurnSourceChannel ? params.turnSourceThreadId : context?.threadId; const rawRequested = params.requestedChannel ?? "last"; const requested = rawRequested === "last" ? "last" : normalizeMessageChannel(rawRequested); From 91ae82ae19e804efd26adcf21c75ec0b1d1d4b42 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:12:10 +0000 Subject: [PATCH 355/408] refactor(sandbox): centralize dangerous docker override key handling --- src/agents/sandbox/config.ts | 30 +++++++--- src/config/config.sandbox-docker.test.ts | 75 +++++++----------------- 2 files changed, 43 insertions(+), 62 deletions(-) diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index 135c9a6520b4..b7595ae8c4b3 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -24,6 +24,26 @@ import type { SandboxScope, } from "./types.js"; +export const DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS = [ + "dangerouslyAllowReservedContainerTargets", + "dangerouslyAllowExternalBindSources", + "dangerouslyAllowContainerNamespaceJoin", +] as const; + +type DangerousSandboxDockerBooleanKey = (typeof DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS)[number]; +type DangerousSandboxDockerBooleans = Pick; + +function resolveDangerousSandboxDockerBooleans( + agentDocker?: Partial, + globalDocker?: Partial, +): DangerousSandboxDockerBooleans { + const resolved = {} as DangerousSandboxDockerBooleans; + for (const key of DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS) { + resolved[key] = agentDocker?.[key] ?? globalDocker?.[key]; + } + return resolved; +} + export function resolveSandboxBrowserDockerCreateConfig(params: { docker: SandboxDockerConfig; browser: SandboxBrowserConfig; @@ -95,15 +115,7 @@ export function resolveSandboxDockerConfig(params: { dns: agentDocker?.dns ?? globalDocker?.dns, extraHosts: agentDocker?.extraHosts ?? globalDocker?.extraHosts, binds: binds.length ? binds : undefined, - dangerouslyAllowReservedContainerTargets: - agentDocker?.dangerouslyAllowReservedContainerTargets ?? - globalDocker?.dangerouslyAllowReservedContainerTargets, - dangerouslyAllowExternalBindSources: - agentDocker?.dangerouslyAllowExternalBindSources ?? - globalDocker?.dangerouslyAllowExternalBindSources, - dangerouslyAllowContainerNamespaceJoin: - agentDocker?.dangerouslyAllowContainerNamespaceJoin ?? - globalDocker?.dangerouslyAllowContainerNamespaceJoin, + ...resolveDangerousSandboxDockerBooleans(agentDocker, globalDocker), }; } diff --git a/src/config/config.sandbox-docker.test.ts b/src/config/config.sandbox-docker.test.ts index 1124eca5fbe1..138a254411d9 100644 --- a/src/config/config.sandbox-docker.test.ts +++ b/src/config/config.sandbox-docker.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS, resolveSandboxBrowserConfig, resolveSandboxDockerConfig, } from "../agents/sandbox/config.js"; @@ -87,61 +88,29 @@ describe("sandbox docker config", () => { expect(res.ok).toBe(true); }); - it("uses agent override precedence for dangerouslyAllowContainerNamespaceJoin", () => { - const inherited = resolveSandboxDockerConfig({ - scope: "agent", - globalDocker: { dangerouslyAllowContainerNamespaceJoin: true }, - agentDocker: {}, - }); - expect(inherited.dangerouslyAllowContainerNamespaceJoin).toBe(true); - - const overridden = resolveSandboxDockerConfig({ - scope: "agent", - globalDocker: { dangerouslyAllowContainerNamespaceJoin: true }, - agentDocker: { dangerouslyAllowContainerNamespaceJoin: false }, - }); - expect(overridden.dangerouslyAllowContainerNamespaceJoin).toBe(false); - }); + it("uses agent override precedence for dangerous sandbox docker booleans", () => { + for (const key of DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS) { + const inherited = resolveSandboxDockerConfig({ + scope: "agent", + globalDocker: { [key]: true }, + agentDocker: {}, + }); + expect(inherited[key]).toBe(true); - it("uses agent override precedence for bind-mount dangerous overrides", () => { - const inherited = resolveSandboxDockerConfig({ - scope: "agent", - globalDocker: { - dangerouslyAllowReservedContainerTargets: true, - dangerouslyAllowExternalBindSources: true, - }, - agentDocker: {}, - }); - expect(inherited.dangerouslyAllowReservedContainerTargets).toBe(true); - expect(inherited.dangerouslyAllowExternalBindSources).toBe(true); + const overridden = resolveSandboxDockerConfig({ + scope: "agent", + globalDocker: { [key]: true }, + agentDocker: { [key]: false }, + }); + expect(overridden[key]).toBe(false); - const overridden = resolveSandboxDockerConfig({ - scope: "agent", - globalDocker: { - dangerouslyAllowReservedContainerTargets: true, - dangerouslyAllowExternalBindSources: true, - }, - agentDocker: { - dangerouslyAllowReservedContainerTargets: false, - dangerouslyAllowExternalBindSources: false, - }, - }); - expect(overridden.dangerouslyAllowReservedContainerTargets).toBe(false); - expect(overridden.dangerouslyAllowExternalBindSources).toBe(false); - - const sharedScope = resolveSandboxDockerConfig({ - scope: "shared", - globalDocker: { - dangerouslyAllowReservedContainerTargets: true, - dangerouslyAllowExternalBindSources: true, - }, - agentDocker: { - dangerouslyAllowReservedContainerTargets: false, - dangerouslyAllowExternalBindSources: false, - }, - }); - expect(sharedScope.dangerouslyAllowReservedContainerTargets).toBe(true); - expect(sharedScope.dangerouslyAllowExternalBindSources).toBe(true); + const sharedScope = resolveSandboxDockerConfig({ + scope: "shared", + globalDocker: { [key]: true }, + agentDocker: { [key]: false }, + }); + expect(sharedScope[key]).toBe(true); + } }); it("rejects seccomp unconfined via Zod schema validation", () => { From 24d7612ddfa4cbf3482fe29dda02557392d0d021 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:12:59 +0000 Subject: [PATCH 356/408] refactor(heartbeat): harden dm delivery classification --- CHANGELOG.md | 4 +- docs/gateway/configuration-reference.md | 2 +- docs/gateway/heartbeat.md | 4 +- .../heartbeat-runner.ghost-reminder.test.ts | 2 +- ...tbeat-runner.returns-default-unset.test.ts | 82 +++++----- src/infra/heartbeat-runner.ts | 62 +++++--- src/infra/outbound/targets.test.ts | 100 ++++++++++++- src/infra/outbound/targets.ts | 141 +++++++++++++----- 8 files changed, 292 insertions(+), 105 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d3b33609915..f05b11ae77d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,11 @@ Docs: https://docs.openclaw.ai ### Breaking - **BREAKING:** Security/Sandbox: block Docker `network: "container:"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting. -- **BREAKING:** Heartbeat delivery now blocks DM-style `user:` targets. Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages. +- **BREAKING:** Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages. ### Fixes -- Heartbeat routing: prevent heartbeat leakage/spam into Discord DMs by blocking DM-style heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871) +- Heartbeat routing: prevent heartbeat leakage/spam into Discord and other direct-message destinations by blocking direct-chat heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871) - iMessage/Reasoning safety: harden iMessage echo suppression with outbound `messageId` matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb. - Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 `dev=0` stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false `Local media path is not safe to read` drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng. - Windows/Exec shell selection: prefer PowerShell 7 (`pwsh`) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing `&&` command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 050106968f44..01ad82b6098c 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -812,7 +812,7 @@ Periodic heartbeat runs. - `every`: duration string (ms/s/m/h). Default: `30m`. - `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs. -- Heartbeats never deliver to DM-style `user:` targets; those runs still execute, but outbound delivery is skipped. +- Heartbeats never deliver to direct/DM chat targets when the destination can be classified as direct (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs); those runs still execute, but outbound delivery is skipped. - Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats. - Heartbeats run full agent turns — shorter intervals burn more tokens. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index c2a762bb6a02..cf7ea489c40b 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -215,7 +215,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `last`: deliver to the last used external channel. - explicit channel: `whatsapp` / `telegram` / `discord` / `googlechat` / `slack` / `msteams` / `signal` / `imessage`. - `none` (default): run the heartbeat but **do not deliver** externally. -- DM-style heartbeat destinations are blocked (`user:` targets resolve to no-delivery). +- Direct/DM heartbeat destinations are blocked when target parsing identifies a direct chat (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). For Telegram topics/threads, use `:topic:`. - `accountId`: optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped. - `prompt`: overrides the default prompt body (not merged). @@ -236,7 +236,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `session` only affects the run context; delivery is controlled by `target` and `to`. - To deliver to a specific channel/recipient, set `target` + `to`. With `target: "last"`, delivery uses the last external channel for that session. -- Heartbeat deliveries never send to DM-style `user:` targets; those runs still execute, but outbound delivery is skipped. +- Heartbeat deliveries never send to direct/DM targets when the destination is identified as direct; those runs still execute, but outbound delivery is skipped. - If the main queue is busy, the heartbeat is skipped and retried later. - If `target` resolves to no external destination, the run still happens but no outbound message is sent. diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index 2696d4bdb032..648acf1813cf 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -55,7 +55,7 @@ describe("Ghost reminder bug (issue #13317)", () => { const sessionKey = await seedMainSessionStore(params.storePath, cfg, { lastChannel: "telegram", lastProvider: "telegram", - lastTo: "155462274", + lastTo: "-100155462274", }); return { cfg, sessionKey }; diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 2f1748bae1b5..0ec2afcafdd5 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -241,7 +241,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { { name: "target defaults to none when unset", cfg: {}, - entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "+1555" }, + entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "120363401234567890@g.us" }, expected: { channel: "none", reason: "target-none", @@ -253,13 +253,15 @@ describe("resolveHeartbeatDeliveryTarget", () => { { name: "normalize explicit whatsapp target when allowFrom wildcard", cfg: { - agents: { defaults: { heartbeat: { target: "whatsapp", to: "whatsapp:(555) 123" } } }, + agents: { + defaults: { heartbeat: { target: "whatsapp", to: "whatsapp:120363401234567890@G.US" } }, + }, channels: { whatsapp: { allowFrom: ["*"] } }, }, entry: baseEntry, expected: { channel: "whatsapp", - to: "+555123", + to: "120363401234567890@g.us", accountId: undefined, lastChannel: undefined, lastAccountId: undefined, @@ -281,7 +283,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { name: "reject explicit whatsapp target outside allowFrom", cfg: { agents: { defaults: { heartbeat: { target: "whatsapp", to: "+1999" } } }, - channels: { whatsapp: { allowFrom: ["+1555", "+1666"] } }, + channels: { whatsapp: { allowFrom: ["120363401234567890@g.us", "+1666"] } }, }, entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "+1222" }, expected: { @@ -296,7 +298,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { name: "normalize prefixed whatsapp group targets", cfg: { agents: { defaults: { heartbeat: { target: "last" } } }, - channels: { whatsapp: { allowFrom: ["+1555"] } }, + channels: { whatsapp: { allowFrom: ["120363401234567890@g.us"] } }, }, entry: { ...baseEntry, @@ -313,11 +315,11 @@ describe("resolveHeartbeatDeliveryTarget", () => { }, { name: "keep explicit telegram target", - cfg: { agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } } }, + cfg: { agents: { defaults: { heartbeat: { target: "telegram", to: "-100123" } } } }, entry: baseEntry, expected: { channel: "telegram", - to: "123", + to: "-100123", accountId: undefined, lastChannel: undefined, lastAccountId: undefined, @@ -358,7 +360,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { accountId: "work", expected: { channel: "telegram", - to: "123", + to: "-100123", accountId: "work", lastChannel: undefined, lastAccountId: undefined, @@ -380,7 +382,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { const cfg: OpenClawConfig = { agents: { defaults: { - heartbeat: { target: "telegram", to: "123", accountId: testCase.accountId }, + heartbeat: { target: "telegram", to: "-100123", accountId: testCase.accountId }, }, }, channels: { telegram: { accounts: { work: { botToken: "token" } } } }, @@ -391,9 +393,9 @@ describe("resolveHeartbeatDeliveryTarget", () => { it("prefers per-agent heartbeat overrides when provided", () => { const cfg: OpenClawConfig = { - agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } }, + agents: { defaults: { heartbeat: { target: "telegram", to: "-100123" } } }, }; - const heartbeat = { target: "whatsapp", to: "+1555" } as const; + const heartbeat = { target: "whatsapp", to: "120363401234567890@g.us" } as const; expect( resolveHeartbeatDeliveryTarget({ cfg, @@ -402,7 +404,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { }), ).toEqual({ channel: "whatsapp", - to: "+1555", + to: "120363401234567890@g.us", accountId: undefined, lastChannel: "whatsapp", lastAccountId: undefined, @@ -518,7 +520,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -535,7 +537,11 @@ describe("runHeartbeatOnce", () => { }); expect(sendWhatsApp).toHaveBeenCalledTimes(1); - expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object)); + expect(sendWhatsApp).toHaveBeenCalledWith( + "120363401234567890@g.us", + "Final alert", + expect.any(Object), + ); } finally { replySpy.mockRestore(); } @@ -572,7 +578,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -587,15 +593,19 @@ describe("runHeartbeatOnce", () => { deps: createHeartbeatDeps(sendWhatsApp), }); expect(sendWhatsApp).toHaveBeenCalledTimes(1); - expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object)); + expect(sendWhatsApp).toHaveBeenCalledWith( + "120363401234567890@g.us", + "Final alert", + expect.any(Object), + ); expect(replySpy).toHaveBeenCalledWith( expect.objectContaining({ Body: expect.stringMatching(/Ops check[\s\S]*Current time: /), SessionKey: sessionKey, - From: "+1555", - To: "+1555", + From: "120363401234567890@g.us", + To: "120363401234567890@g.us", OriginatingChannel: "whatsapp", - OriginatingTo: "+1555", + OriginatingTo: "120363401234567890@g.us", Provider: "heartbeat", }), expect.objectContaining({ isHeartbeat: true, suppressToolErrorWarnings: false }), @@ -645,7 +655,7 @@ describe("runHeartbeatOnce", () => { sessionFile, updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -663,12 +673,16 @@ describe("runHeartbeatOnce", () => { expect(result.status).toBe("ran"); expect(sendWhatsApp).toHaveBeenCalledTimes(1); - expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object)); + expect(sendWhatsApp).toHaveBeenCalledWith( + "120363401234567890@g.us", + "Final alert", + expect.any(Object), + ); expect(replySpy).toHaveBeenCalledWith( expect.objectContaining({ SessionKey: sessionKey, - From: "+1555", - To: "+1555", + From: "120363401234567890@g.us", + To: "120363401234567890@g.us", Provider: "heartbeat", }), expect.objectContaining({ isHeartbeat: true, suppressToolErrorWarnings: false }), @@ -709,8 +723,8 @@ describe("runHeartbeatOnce", () => { { name: "runHeartbeatOnce sessionKey arg", caseDir: "hb-forced-session-override", - peerKind: "direct" as const, - peerId: "+15559990000", + peerKind: "group" as const, + peerId: "120363401234567891@g.us", message: "Forced alert", applyOverride: () => {}, runOptions: ({ sessionKey }: { sessionKey: string }) => ({ sessionKey }), @@ -750,7 +764,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid-main", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, [overrideSessionKey]: { sessionId: `sid-${testCase.peerKind}`, @@ -819,7 +833,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", lastHeartbeatText: "Final alert", lastHeartbeatSentAt: 0, }, @@ -892,7 +906,7 @@ describe("runHeartbeatOnce", () => { updatedAt: Date.now(), lastChannel: "whatsapp", lastProvider: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -912,7 +926,7 @@ describe("runHeartbeatOnce", () => { for (const [index, text] of testCase.expectedTexts.entries()) { expect(sendWhatsApp, testCase.name).toHaveBeenNthCalledWith( index + 1, - "+1555", + "120363401234567890@g.us", text, expect.any(Object), ); @@ -949,7 +963,7 @@ describe("runHeartbeatOnce", () => { updatedAt: Date.now(), lastChannel: "whatsapp", lastProvider: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -967,7 +981,7 @@ describe("runHeartbeatOnce", () => { expect(sendWhatsApp).toHaveBeenCalledTimes(1); expect(sendWhatsApp).toHaveBeenCalledWith( - "+1555", + "120363401234567890@g.us", "Hello from heartbeat", expect.any(Object), ); @@ -1024,7 +1038,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -1173,7 +1187,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -1226,7 +1240,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index b7ae733e6336..73c2fafb1ae3 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -553,6 +553,40 @@ async function resolveHeartbeatPreflight(params: { return basePreflight; } +type HeartbeatPromptResolution = { + prompt: string; + hasExecCompletion: boolean; + hasCronEvents: boolean; +}; + +function resolveHeartbeatRunPrompt(params: { + cfg: OpenClawConfig; + heartbeat?: HeartbeatConfig; + preflight: HeartbeatPreflight; + canRelayToUser: boolean; +}): HeartbeatPromptResolution { + const pendingEventEntries = params.preflight.pendingEventEntries; + const pendingEvents = params.preflight.shouldInspectPendingEvents + ? pendingEventEntries.map((event) => event.text) + : []; + const cronEvents = pendingEventEntries + .filter( + (event) => + (params.preflight.isCronEventReason || event.contextKey?.startsWith("cron:")) && + isCronSystemEvent(event.text), + ) + .map((event) => event.text); + const hasExecCompletion = pendingEvents.some(isExecCompletionEvent); + const hasCronEvents = cronEvents.length > 0; + const prompt = hasExecCompletion + ? buildExecEventPrompt({ deliverToUser: params.canRelayToUser }) + : hasCronEvents + ? buildCronEventPrompt(cronEvents, { deliverToUser: params.canRelayToUser }) + : resolveHeartbeatPrompt(params.cfg, params.heartbeat); + + return { prompt, hasExecCompletion, hasCronEvents }; +} + export async function runHeartbeatOnce(opts: { cfg?: OpenClawConfig; agentId?: string; @@ -601,7 +635,6 @@ export async function runHeartbeatOnce(opts: { return { status: "skipped", reason: preflight.skipReason }; } const { entry, sessionKey, storePath } = preflight.session; - const { isCronEventReason, pendingEventEntries } = preflight; const previousUpdatedAt = entry?.updatedAt; const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat }); const heartbeatAccountId = heartbeat?.accountId?.trim(); @@ -631,30 +664,15 @@ export async function runHeartbeatOnce(opts: { accountId: delivery.accountId, }).responsePrefix; - // Check if this is an exec event or cron event with pending system events. - // If so, use a specialized prompt that instructs the model to relay the result - // instead of the standard heartbeat prompt with "reply HEARTBEAT_OK". - const shouldInspectPendingEvents = preflight.shouldInspectPendingEvents; - const pendingEvents = shouldInspectPendingEvents - ? pendingEventEntries.map((event) => event.text) - : []; - const cronEvents = pendingEventEntries - .filter( - (event) => - (isCronEventReason || event.contextKey?.startsWith("cron:")) && - isCronSystemEvent(event.text), - ) - .map((event) => event.text); - const hasExecCompletion = pendingEvents.some(isExecCompletionEvent); - const hasCronEvents = cronEvents.length > 0; const canRelayToUser = Boolean( delivery.channel !== "none" && delivery.to && visibility.showAlerts, ); - const prompt = hasExecCompletion - ? buildExecEventPrompt({ deliverToUser: canRelayToUser }) - : hasCronEvents - ? buildCronEventPrompt(cronEvents, { deliverToUser: canRelayToUser }) - : resolveHeartbeatPrompt(cfg, heartbeat); + const { prompt, hasExecCompletion, hasCronEvents } = resolveHeartbeatRunPrompt({ + cfg, + heartbeat, + preflight, + canRelayToUser, + }); const ctx = { Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt), From: sender, diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 24b7343e9bff..8f120702de08 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -341,6 +341,102 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.reason).toBe("dm-blocked"); }); + it("blocks heartbeat delivery to Telegram direct chats", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-telegram-direct", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "5232990709", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); + }); + + it("keeps heartbeat delivery to Telegram groups", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-telegram-group", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "-1001234567890", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("-1001234567890"); + }); + + it("blocks heartbeat delivery to WhatsApp direct chats", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-whatsapp-direct", + updatedAt: 1, + lastChannel: "whatsapp", + lastTo: "+15551234567", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); + }); + + it("keeps heartbeat delivery to WhatsApp groups", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-whatsapp-group", + updatedAt: 1, + lastChannel: "whatsapp", + lastTo: "120363140186826074@g.us", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("whatsapp"); + expect(resolved.to).toBe("120363140186826074@g.us"); + }); + + it("uses session chatType hint when target parser cannot classify", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-imessage-direct", + updatedAt: 1, + lastChannel: "imessage", + lastTo: "chat-guid-unknown-shape", + chatType: "direct", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); + }); + it("keeps heartbeat delivery to Discord channels", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ @@ -386,12 +482,12 @@ describe("resolveSessionDeliveryTarget", () => { cfg, heartbeat: { target: "telegram", - to: "63448508:topic:1008013", + to: "-10063448508:topic:1008013", }, }); expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("63448508"); + expect(resolved.to).toBe("-10063448508"); expect(resolved.threadId).toBe(1008013); }); }); diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index cf08ac74db85..41baa5586539 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -1,4 +1,4 @@ -import type { ChatType } from "../../channels/chat-type.js"; +import { normalizeChatType, type ChatType } from "../../channels/chat-type.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js"; import { formatCliCommand } from "../../cli/command-format.js"; @@ -8,7 +8,7 @@ import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; import { parseDiscordTarget } from "../../discord/targets.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import { parseSlackTarget } from "../../slack/targets.js"; -import { parseTelegramTarget } from "../../telegram/targets.js"; +import { parseTelegramTarget, resolveTelegramTargetChatType } from "../../telegram/targets.js"; import { deliveryContextFromSession } from "../../utils/delivery-context.js"; import type { DeliverableMessageChannel, @@ -19,6 +19,7 @@ import { isDeliverableMessageChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; +import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; import { missingTargetError } from "./target-errors.js"; export type OutboundChannel = DeliverableMessageChannel | "none"; @@ -249,13 +250,11 @@ export function resolveHeartbeatDeliveryTarget(params: { if (target === "none") { const base = resolveSessionDeliveryTarget({ entry }); - return { - channel: "none", + return buildNoHeartbeatDeliveryTarget({ reason: "target-none", - accountId: undefined, lastChannel: base.lastChannel, lastAccountId: base.lastAccountId, - }; + }); } const resolvedTarget = resolveSessionDeliveryTarget({ @@ -279,26 +278,24 @@ export function resolveHeartbeatDeliveryTarget(params: { accountIds.map((accountId) => normalizeAccountId(accountId)), ); if (!normalizedAccountIds.has(normalizedAccountId)) { - return { - channel: "none", + return buildNoHeartbeatDeliveryTarget({ reason: "unknown-account", accountId: normalizedAccountId, lastChannel: resolvedTarget.lastChannel, lastAccountId: resolvedTarget.lastAccountId, - }; + }); } effectiveAccountId = normalizedAccountId; } } if (!resolvedTarget.channel || !resolvedTarget.to) { - return { - channel: "none", + return buildNoHeartbeatDeliveryTarget({ reason: "no-target", accountId: effectiveAccountId, lastChannel: resolvedTarget.lastChannel, lastAccountId: resolvedTarget.lastAccountId, - }; + }); } const resolved = resolveOutboundTarget({ @@ -309,27 +306,28 @@ export function resolveHeartbeatDeliveryTarget(params: { mode: "heartbeat", }); if (!resolved.ok) { - return { - channel: "none", + return buildNoHeartbeatDeliveryTarget({ reason: "no-target", accountId: effectiveAccountId, lastChannel: resolvedTarget.lastChannel, lastAccountId: resolvedTarget.lastAccountId, - }; + }); } + const sessionChatTypeHint = + target === "last" && !heartbeat?.to ? normalizeChatType(entry?.chatType) : undefined; const deliveryChatType = resolveHeartbeatDeliveryChatType({ channel: resolvedTarget.channel, to: resolved.to, + sessionChatType: sessionChatTypeHint, }); if (deliveryChatType === "direct") { - return { - channel: "none", + return buildNoHeartbeatDeliveryTarget({ reason: "dm-blocked", accountId: effectiveAccountId, lastChannel: resolvedTarget.lastChannel, lastAccountId: resolvedTarget.lastAccountId, - }; + }); } let reason: string | undefined; @@ -358,6 +356,85 @@ export function resolveHeartbeatDeliveryTarget(params: { }; } +function buildNoHeartbeatDeliveryTarget(params: { + reason: string; + accountId?: string; + lastChannel?: DeliverableMessageChannel; + lastAccountId?: string; +}): OutboundTarget { + return { + channel: "none", + reason: params.reason, + accountId: params.accountId, + lastChannel: params.lastChannel, + lastAccountId: params.lastAccountId, + }; +} + +function inferDiscordTargetChatType(to: string): ChatType | undefined { + try { + const target = parseDiscordTarget(to, { defaultKind: "channel" }); + if (!target) { + return undefined; + } + return target.kind === "user" ? "direct" : "channel"; + } catch { + return undefined; + } +} + +function inferSlackTargetChatType(to: string): ChatType | undefined { + const target = parseSlackTarget(to, { defaultKind: "channel" }); + if (!target) { + return undefined; + } + return target.kind === "user" ? "direct" : "channel"; +} + +function inferTelegramTargetChatType(to: string): ChatType | undefined { + const chatType = resolveTelegramTargetChatType(to); + return chatType === "unknown" ? undefined : chatType; +} + +function inferWhatsAppTargetChatType(to: string): ChatType | undefined { + const normalized = normalizeWhatsAppTarget(to); + if (!normalized) { + return undefined; + } + return isWhatsAppGroupJid(normalized) ? "group" : "direct"; +} + +function inferSignalTargetChatType(rawTo: string): ChatType | undefined { + let to = rawTo.trim(); + if (!to) { + return undefined; + } + if (/^signal:/i.test(to)) { + to = to.replace(/^signal:/i, "").trim(); + } + if (!to) { + return undefined; + } + const lower = to.toLowerCase(); + if (lower.startsWith("group:")) { + return "group"; + } + if (lower.startsWith("username:") || lower.startsWith("u:")) { + return "direct"; + } + return "direct"; +} + +const HEARTBEAT_TARGET_CHAT_TYPE_INFERERS: Partial< + Record ChatType | undefined> +> = { + discord: inferDiscordTargetChatType, + slack: inferSlackTargetChatType, + telegram: inferTelegramTargetChatType, + whatsapp: inferWhatsAppTargetChatType, + signal: inferSignalTargetChatType, +}; + function inferChatTypeFromTarget(params: { channel: DeliverableMessageChannel; to: string; @@ -376,35 +453,17 @@ function inferChatTypeFromTarget(params: { if (/^group:/i.test(to)) { return "group"; } - - switch (params.channel) { - case "discord": { - try { - const target = parseDiscordTarget(to, { defaultKind: "channel" }); - if (!target) { - return undefined; - } - return target.kind === "user" ? "direct" : "channel"; - } catch { - return undefined; - } - } - case "slack": { - const target = parseSlackTarget(to, { defaultKind: "channel" }); - if (!target) { - return undefined; - } - return target.kind === "user" ? "direct" : "channel"; - } - default: - return undefined; - } + return HEARTBEAT_TARGET_CHAT_TYPE_INFERERS[params.channel]?.(to); } function resolveHeartbeatDeliveryChatType(params: { channel: DeliverableMessageChannel; to: string; + sessionChatType?: ChatType; }): ChatType | undefined { + if (params.sessionChatType) { + return params.sessionChatType; + } return inferChatTypeFromTarget({ channel: params.channel, to: params.to, From d42ef2ac62166eb9fac359e88bca4447de28e82e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:15:54 +0000 Subject: [PATCH 357/408] refactor: consolidate typing lifecycle and queue policy --- .../matrix/src/matrix/monitor/handler.ts | 4 +- .../mattermost/src/mattermost/monitor.ts | 4 +- extensions/msteams/src/reply-dispatcher.ts | 4 +- src/auto-reply/reply/agent-runner.ts | 12 +- src/auto-reply/reply/queue-policy.test.ts | 48 ++++ src/auto-reply/reply/queue-policy.ts | 21 ++ src/auto-reply/reply/reply-dispatcher.ts | 15 +- src/auto-reply/reply/typing.ts | 23 +- src/channels/channel-helpers.test.ts | 237 ------------------ src/channels/conversation-label.test.ts | 71 ++++++ src/channels/registry.helpers.test.ts | 42 ++++ src/channels/targets.test.ts | 39 +++ src/channels/typing-lifecycle.ts | 55 ++++ src/channels/typing.test.ts | 88 +++++++ src/channels/typing.ts | 33 +-- .../monitor/message-handler.process.ts | 3 +- src/signal/monitor/event-handler.ts | 4 +- src/slack/monitor/message-handler/dispatch.ts | 4 +- src/telegram/bot-message-dispatch.ts | 4 +- 19 files changed, 410 insertions(+), 301 deletions(-) create mode 100644 src/auto-reply/reply/queue-policy.test.ts create mode 100644 src/auto-reply/reply/queue-policy.ts delete mode 100644 src/channels/channel-helpers.test.ts create mode 100644 src/channels/conversation-label.test.ts create mode 100644 src/channels/registry.helpers.test.ts create mode 100644 src/channels/targets.test.ts create mode 100644 src/channels/typing-lifecycle.ts create mode 100644 src/channels/typing.test.ts diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index f62ef60ce3b7..77e88162af35 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -635,6 +635,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam core.channel.reply.createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + typingCallbacks, deliver: async (payload) => { await deliverMatrixReplies({ replies: [payload], @@ -652,9 +653,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam onError: (err, info) => { runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); }, - onReplyStart: typingCallbacks.onReplyStart, - onIdle: typingCallbacks.onIdle, - onCleanup: typingCallbacks.onCleanup, }); const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 233aee17d1b8..6056c3fef156 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -768,6 +768,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} core.channel.reply.createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + typingCallbacks, deliver: async (payload: ReplyPayload) => { const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); @@ -804,9 +805,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} onError: (err, info) => { runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`); }, - onReplyStart: typingCallbacks.onReplyStart, - onIdle: typingCallbacks.onIdle, - onCleanup: typingCallbacks.onCleanup, }); await core.channel.reply.dispatchReplyFromConfig({ diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 755ae3e56e12..36d611c39dad 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -68,6 +68,7 @@ export function createMSTeamsReplyDispatcher(params: { core.channel.reply.createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), + typingCallbacks, deliver: async (payload) => { const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg: params.cfg, @@ -121,9 +122,6 @@ export function createMSTeamsReplyDispatcher(params: { hint, }); }, - onReplyStart: typingCallbacks.onReplyStart, - onIdle: typingCallbacks.onIdle, - onCleanup: typingCallbacks.onCleanup, }); return { diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 49e7408357e3..8628fe33a514 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -51,6 +51,7 @@ import { readSessionMessages, } from "./post-compaction-audit.js"; import { readPostCompactionContext } from "./post-compaction-context.js"; +import { resolveActiveRunQueueAction } from "./queue-policy.js"; import { enqueueFollowupRun, type FollowupRun, type QueueSettings } from "./queue.js"; import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js"; import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-run-accounting.js"; @@ -235,12 +236,19 @@ export async function runReplyAgent(params: { } } - if (isHeartbeat && isActive) { + const activeRunQueueAction = resolveActiveRunQueueAction({ + isActive, + isHeartbeat, + shouldFollowup, + queueMode: resolvedQueue.mode, + }); + + if (activeRunQueueAction === "drop") { typing.cleanup(); return undefined; } - if (isActive && (shouldFollowup || resolvedQueue.mode === "steer")) { + if (activeRunQueueAction === "enqueue-followup") { enqueueFollowupRun(queueKey, followupRun, resolvedQueue); await touchActiveSessionEntry(); typing.cleanup(); diff --git a/src/auto-reply/reply/queue-policy.test.ts b/src/auto-reply/reply/queue-policy.test.ts new file mode 100644 index 000000000000..265cc19ff9b6 --- /dev/null +++ b/src/auto-reply/reply/queue-policy.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { resolveActiveRunQueueAction } from "./queue-policy.js"; + +describe("resolveActiveRunQueueAction", () => { + it("runs immediately when there is no active run", () => { + expect( + resolveActiveRunQueueAction({ + isActive: false, + isHeartbeat: false, + shouldFollowup: true, + queueMode: "collect", + }), + ).toBe("run-now"); + }); + + it("drops heartbeat runs while another run is active", () => { + expect( + resolveActiveRunQueueAction({ + isActive: true, + isHeartbeat: true, + shouldFollowup: true, + queueMode: "collect", + }), + ).toBe("drop"); + }); + + it("enqueues followups for non-heartbeat active runs", () => { + expect( + resolveActiveRunQueueAction({ + isActive: true, + isHeartbeat: false, + shouldFollowup: true, + queueMode: "collect", + }), + ).toBe("enqueue-followup"); + }); + + it("enqueues steer mode runs while active", () => { + expect( + resolveActiveRunQueueAction({ + isActive: true, + isHeartbeat: false, + shouldFollowup: false, + queueMode: "steer", + }), + ).toBe("enqueue-followup"); + }); +}); diff --git a/src/auto-reply/reply/queue-policy.ts b/src/auto-reply/reply/queue-policy.ts new file mode 100644 index 000000000000..73fc48bdcc67 --- /dev/null +++ b/src/auto-reply/reply/queue-policy.ts @@ -0,0 +1,21 @@ +import type { QueueSettings } from "./queue.js"; + +export type ActiveRunQueueAction = "run-now" | "enqueue-followup" | "drop"; + +export function resolveActiveRunQueueAction(params: { + isActive: boolean; + isHeartbeat: boolean; + shouldFollowup: boolean; + queueMode: QueueSettings["mode"]; +}): ActiveRunQueueAction { + if (!params.isActive) { + return "run-now"; + } + if (params.isHeartbeat) { + return "drop"; + } + if (params.shouldFollowup || params.queueMode === "steer") { + return "enqueue-followup"; + } + return "run-now"; +} diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index bfc5fa20f0f5..5c015dcd5577 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -1,3 +1,4 @@ +import type { TypingCallbacks } from "../../channels/typing.js"; import type { HumanDelayConfig } from "../../config/types.js"; import { sleep } from "../../utils.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; @@ -57,6 +58,7 @@ export type ReplyDispatcherOptions = { }; export type ReplyDispatcherWithTypingOptions = Omit & { + typingCallbacks?: TypingCallbacks; onReplyStart?: () => Promise | void; onIdle?: () => void; /** Called when the typing controller is cleaned up (e.g., on NO_REPLY). */ @@ -209,28 +211,31 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis export function createReplyDispatcherWithTyping( options: ReplyDispatcherWithTypingOptions, ): ReplyDispatcherWithTypingResult { - const { onReplyStart, onIdle, onCleanup, ...dispatcherOptions } = options; + const { typingCallbacks, onReplyStart, onIdle, onCleanup, ...dispatcherOptions } = options; + const resolvedOnReplyStart = onReplyStart ?? typingCallbacks?.onReplyStart; + const resolvedOnIdle = onIdle ?? typingCallbacks?.onIdle; + const resolvedOnCleanup = onCleanup ?? typingCallbacks?.onCleanup; let typingController: TypingController | undefined; const dispatcher = createReplyDispatcher({ ...dispatcherOptions, onIdle: () => { typingController?.markDispatchIdle(); - onIdle?.(); + resolvedOnIdle?.(); }, }); return { dispatcher, replyOptions: { - onReplyStart, - onTypingCleanup: onCleanup, + onReplyStart: resolvedOnReplyStart, + onTypingCleanup: resolvedOnCleanup, onTypingController: (typing) => { typingController = typing; }, }, markDispatchIdle: () => { typingController?.markDispatchIdle(); - onIdle?.(); + resolvedOnIdle?.(); }, }; } diff --git a/src/auto-reply/reply/typing.ts b/src/auto-reply/reply/typing.ts index ececcc2fb842..fee32418050d 100644 --- a/src/auto-reply/reply/typing.ts +++ b/src/auto-reply/reply/typing.ts @@ -1,3 +1,4 @@ +import { createTypingKeepaliveLoop } from "../../channels/typing-lifecycle.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; export type TypingController = { @@ -35,7 +36,6 @@ export function createTypingController(params: { // especially when upstream event emitters don't await async listeners. // Once we stop typing, we "seal" the controller so late events can't restart typing forever. let sealed = false; - let typingTimer: NodeJS.Timeout | undefined; let typingTtlTimer: NodeJS.Timeout | undefined; const typingIntervalMs = typingIntervalSeconds * 1000; @@ -61,10 +61,7 @@ export function createTypingController(params: { clearTimeout(typingTtlTimer); typingTtlTimer = undefined; } - if (typingTimer) { - clearInterval(typingTimer); - typingTimer = undefined; - } + typingLoop.stop(); // Notify the channel to stop its typing indicator (e.g., on NO_REPLY). // This fires only once (sealed prevents re-entry). if (active) { @@ -88,7 +85,7 @@ export function createTypingController(params: { clearTimeout(typingTtlTimer); } typingTtlTimer = setTimeout(() => { - if (!typingTimer) { + if (!typingLoop.isRunning()) { return; } log?.(`typing TTL reached (${formatTypingTtl(typingTtlMs)}); stopping typing indicator`); @@ -105,6 +102,11 @@ export function createTypingController(params: { await onReplyStart?.(); }; + const typingLoop = createTypingKeepaliveLoop({ + intervalMs: typingIntervalMs, + onTick: triggerTyping, + }); + const ensureStart = async () => { if (sealed) { return; @@ -146,16 +148,11 @@ export function createTypingController(params: { if (!onReplyStart) { return; } - if (typingIntervalMs <= 0) { - return; - } - if (typingTimer) { + if (typingLoop.isRunning()) { return; } await ensureStart(); - typingTimer = setInterval(() => { - void triggerTyping(); - }, typingIntervalMs); + typingLoop.start(); }; const startTypingOnText = async (text?: string) => { diff --git a/src/channels/channel-helpers.test.ts b/src/channels/channel-helpers.test.ts deleted file mode 100644 index 34bd5370526c..000000000000 --- a/src/channels/channel-helpers.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { MsgContext } from "../auto-reply/templating.js"; -import { resolveConversationLabel } from "./conversation-label.js"; -import { - formatChannelSelectionLine, - listChatChannels, - normalizeChatChannelId, -} from "./registry.js"; -import { buildMessagingTarget, ensureTargetId, requireTargetKind } from "./targets.js"; -import { createTypingCallbacks } from "./typing.js"; - -const flushMicrotasks = async () => { - await Promise.resolve(); - await Promise.resolve(); -}; - -describe("channel registry helpers", () => { - it("normalizes aliases + trims whitespace", () => { - expect(normalizeChatChannelId(" imsg ")).toBe("imessage"); - expect(normalizeChatChannelId("gchat")).toBe("googlechat"); - expect(normalizeChatChannelId("google-chat")).toBe("googlechat"); - expect(normalizeChatChannelId("internet-relay-chat")).toBe("irc"); - expect(normalizeChatChannelId("telegram")).toBe("telegram"); - expect(normalizeChatChannelId("web")).toBeNull(); - expect(normalizeChatChannelId("nope")).toBeNull(); - }); - - it("keeps Telegram first in the default order", () => { - const channels = listChatChannels(); - expect(channels[0]?.id).toBe("telegram"); - }); - - it("does not include MS Teams by default", () => { - const channels = listChatChannels(); - expect(channels.some((channel) => channel.id === "msteams")).toBe(false); - }); - - it("formats selection lines with docs labels + website extras", () => { - const channels = listChatChannels(); - const first = channels[0]; - if (!first) { - throw new Error("Missing channel metadata."); - } - const line = formatChannelSelectionLine(first, (path, label) => - [label, path].filter(Boolean).join(":"), - ); - expect(line).not.toContain("Docs:"); - expect(line).toContain("/channels/telegram"); - expect(line).toContain("https://openclaw.ai"); - }); -}); - -describe("channel targets", () => { - it("ensureTargetId returns the candidate when it matches", () => { - expect( - ensureTargetId({ - candidate: "U123", - pattern: /^[A-Z0-9]+$/i, - errorMessage: "bad", - }), - ).toBe("U123"); - }); - - it("ensureTargetId throws with the provided message on mismatch", () => { - expect(() => - ensureTargetId({ - candidate: "not-ok", - pattern: /^[A-Z0-9]+$/i, - errorMessage: "Bad target", - }), - ).toThrow(/Bad target/); - }); - - it("requireTargetKind returns the target id when the kind matches", () => { - const target = buildMessagingTarget("channel", "C123", "C123"); - expect(requireTargetKind({ platform: "Slack", target, kind: "channel" })).toBe("C123"); - }); - - it("requireTargetKind throws when the kind is missing or mismatched", () => { - expect(() => - requireTargetKind({ platform: "Slack", target: undefined, kind: "channel" }), - ).toThrow(/Slack channel id is required/); - const target = buildMessagingTarget("user", "U123", "U123"); - expect(() => requireTargetKind({ platform: "Slack", target, kind: "channel" })).toThrow( - /Slack channel id is required/, - ); - }); -}); - -describe("resolveConversationLabel", () => { - const cases: Array<{ name: string; ctx: MsgContext; expected: string }> = [ - { - name: "prefers ConversationLabel when present", - ctx: { ConversationLabel: "Pinned Label", ChatType: "group" }, - expected: "Pinned Label", - }, - { - name: "prefers ThreadLabel over derived chat labels", - ctx: { - ThreadLabel: "Thread Alpha", - ChatType: "group", - GroupSubject: "Ops", - From: "telegram:group:42", - }, - expected: "Thread Alpha", - }, - { - name: "uses SenderName for direct chats when available", - ctx: { ChatType: "direct", SenderName: "Ada", From: "telegram:99" }, - expected: "Ada", - }, - { - name: "falls back to From for direct chats when SenderName is missing", - ctx: { ChatType: "direct", From: "telegram:99" }, - expected: "telegram:99", - }, - { - name: "derives Telegram-like group labels with numeric id suffix", - ctx: { ChatType: "group", GroupSubject: "Ops", From: "telegram:group:42" }, - expected: "Ops id:42", - }, - { - name: "does not append ids for #rooms/channels", - ctx: { - ChatType: "channel", - GroupSubject: "#general", - From: "slack:channel:C123", - }, - expected: "#general", - }, - { - name: "does not append ids when the base already contains the id", - ctx: { - ChatType: "group", - GroupSubject: "Family id:123@g.us", - From: "whatsapp:group:123@g.us", - }, - expected: "Family id:123@g.us", - }, - { - name: "appends ids for WhatsApp-like group ids when a subject exists", - ctx: { - ChatType: "group", - GroupSubject: "Family", - From: "whatsapp:group:123@g.us", - }, - expected: "Family id:123@g.us", - }, - ]; - - for (const testCase of cases) { - it(testCase.name, () => { - expect(resolveConversationLabel(testCase.ctx)).toBe(testCase.expected); - }); - } -}); - -describe("createTypingCallbacks", () => { - it("invokes start on reply start", async () => { - const start = vi.fn().mockResolvedValue(undefined); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ start, onStartError }); - - await callbacks.onReplyStart(); - - expect(start).toHaveBeenCalledTimes(1); - expect(onStartError).not.toHaveBeenCalled(); - }); - - it("reports start errors", async () => { - const start = vi.fn().mockRejectedValue(new Error("fail")); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ start, onStartError }); - - await callbacks.onReplyStart(); - - expect(onStartError).toHaveBeenCalledTimes(1); - }); - - it("invokes stop on idle and reports stop errors", async () => { - const start = vi.fn().mockResolvedValue(undefined); - const stop = vi.fn().mockRejectedValue(new Error("stop")); - const onStartError = vi.fn(); - const onStopError = vi.fn(); - const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError }); - - callbacks.onIdle?.(); - await flushMicrotasks(); - - expect(stop).toHaveBeenCalledTimes(1); - expect(onStopError).toHaveBeenCalledTimes(1); - }); - - it("sends typing keepalive pings until idle cleanup", async () => { - vi.useFakeTimers(); - try { - const start = vi.fn().mockResolvedValue(undefined); - const stop = vi.fn().mockResolvedValue(undefined); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ start, stop, onStartError }); - - await callbacks.onReplyStart(); - expect(start).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(2_999); - expect(start).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(1); - expect(start).toHaveBeenCalledTimes(2); - - await vi.advanceTimersByTimeAsync(3_000); - expect(start).toHaveBeenCalledTimes(3); - - callbacks.onIdle?.(); - await flushMicrotasks(); - expect(stop).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(9_000); - expect(start).toHaveBeenCalledTimes(3); - } finally { - vi.useRealTimers(); - } - }); - - it("deduplicates stop across idle and cleanup", async () => { - const start = vi.fn().mockResolvedValue(undefined); - const stop = vi.fn().mockResolvedValue(undefined); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ start, stop, onStartError }); - - callbacks.onIdle?.(); - callbacks.onCleanup?.(); - await flushMicrotasks(); - - expect(stop).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/channels/conversation-label.test.ts b/src/channels/conversation-label.test.ts new file mode 100644 index 000000000000..9d9e042ad0c8 --- /dev/null +++ b/src/channels/conversation-label.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import type { MsgContext } from "../auto-reply/templating.js"; +import { resolveConversationLabel } from "./conversation-label.js"; + +describe("resolveConversationLabel", () => { + const cases: Array<{ name: string; ctx: MsgContext; expected: string }> = [ + { + name: "prefers ConversationLabel when present", + ctx: { ConversationLabel: "Pinned Label", ChatType: "group" }, + expected: "Pinned Label", + }, + { + name: "prefers ThreadLabel over derived chat labels", + ctx: { + ThreadLabel: "Thread Alpha", + ChatType: "group", + GroupSubject: "Ops", + From: "telegram:group:42", + }, + expected: "Thread Alpha", + }, + { + name: "uses SenderName for direct chats when available", + ctx: { ChatType: "direct", SenderName: "Ada", From: "telegram:99" }, + expected: "Ada", + }, + { + name: "falls back to From for direct chats when SenderName is missing", + ctx: { ChatType: "direct", From: "telegram:99" }, + expected: "telegram:99", + }, + { + name: "derives Telegram-like group labels with numeric id suffix", + ctx: { ChatType: "group", GroupSubject: "Ops", From: "telegram:group:42" }, + expected: "Ops id:42", + }, + { + name: "does not append ids for #rooms/channels", + ctx: { + ChatType: "channel", + GroupSubject: "#general", + From: "slack:channel:C123", + }, + expected: "#general", + }, + { + name: "does not append ids when the base already contains the id", + ctx: { + ChatType: "group", + GroupSubject: "Family id:123@g.us", + From: "whatsapp:group:123@g.us", + }, + expected: "Family id:123@g.us", + }, + { + name: "appends ids for WhatsApp-like group ids when a subject exists", + ctx: { + ChatType: "group", + GroupSubject: "Family", + From: "whatsapp:group:123@g.us", + }, + expected: "Family id:123@g.us", + }, + ]; + + for (const testCase of cases) { + it(testCase.name, () => { + expect(resolveConversationLabel(testCase.ctx)).toBe(testCase.expected); + }); + } +}); diff --git a/src/channels/registry.helpers.test.ts b/src/channels/registry.helpers.test.ts new file mode 100644 index 000000000000..3051f33b4fac --- /dev/null +++ b/src/channels/registry.helpers.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + formatChannelSelectionLine, + listChatChannels, + normalizeChatChannelId, +} from "./registry.js"; + +describe("channel registry helpers", () => { + it("normalizes aliases + trims whitespace", () => { + expect(normalizeChatChannelId(" imsg ")).toBe("imessage"); + expect(normalizeChatChannelId("gchat")).toBe("googlechat"); + expect(normalizeChatChannelId("google-chat")).toBe("googlechat"); + expect(normalizeChatChannelId("internet-relay-chat")).toBe("irc"); + expect(normalizeChatChannelId("telegram")).toBe("telegram"); + expect(normalizeChatChannelId("web")).toBeNull(); + expect(normalizeChatChannelId("nope")).toBeNull(); + }); + + it("keeps Telegram first in the default order", () => { + const channels = listChatChannels(); + expect(channels[0]?.id).toBe("telegram"); + }); + + it("does not include MS Teams by default", () => { + const channels = listChatChannels(); + expect(channels.some((channel) => channel.id === "msteams")).toBe(false); + }); + + it("formats selection lines with docs labels + website extras", () => { + const channels = listChatChannels(); + const first = channels[0]; + if (!first) { + throw new Error("Missing channel metadata."); + } + const line = formatChannelSelectionLine(first, (path, label) => + [label, path].filter(Boolean).join(":"), + ); + expect(line).not.toContain("Docs:"); + expect(line).toContain("/channels/telegram"); + expect(line).toContain("https://openclaw.ai"); + }); +}); diff --git a/src/channels/targets.test.ts b/src/channels/targets.test.ts new file mode 100644 index 000000000000..cea399998367 --- /dev/null +++ b/src/channels/targets.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { buildMessagingTarget, ensureTargetId, requireTargetKind } from "./targets.js"; + +describe("channel targets", () => { + it("ensureTargetId returns the candidate when it matches", () => { + expect( + ensureTargetId({ + candidate: "U123", + pattern: /^[A-Z0-9]+$/i, + errorMessage: "bad", + }), + ).toBe("U123"); + }); + + it("ensureTargetId throws with the provided message on mismatch", () => { + expect(() => + ensureTargetId({ + candidate: "not-ok", + pattern: /^[A-Z0-9]+$/i, + errorMessage: "Bad target", + }), + ).toThrow(/Bad target/); + }); + + it("requireTargetKind returns the target id when the kind matches", () => { + const target = buildMessagingTarget("channel", "C123", "C123"); + expect(requireTargetKind({ platform: "Slack", target, kind: "channel" })).toBe("C123"); + }); + + it("requireTargetKind throws when the kind is missing or mismatched", () => { + expect(() => + requireTargetKind({ platform: "Slack", target: undefined, kind: "channel" }), + ).toThrow(/Slack channel id is required/); + const target = buildMessagingTarget("user", "U123", "U123"); + expect(() => requireTargetKind({ platform: "Slack", target, kind: "channel" })).toThrow( + /Slack channel id is required/, + ); + }); +}); diff --git a/src/channels/typing-lifecycle.ts b/src/channels/typing-lifecycle.ts new file mode 100644 index 000000000000..68cab9113ae9 --- /dev/null +++ b/src/channels/typing-lifecycle.ts @@ -0,0 +1,55 @@ +type AsyncTick = () => Promise | void; + +export type TypingKeepaliveLoop = { + tick: () => Promise; + start: () => void; + stop: () => void; + isRunning: () => boolean; +}; + +export function createTypingKeepaliveLoop(params: { + intervalMs: number; + onTick: AsyncTick; +}): TypingKeepaliveLoop { + let timer: ReturnType | undefined; + let tickInFlight = false; + + const tick = async () => { + if (tickInFlight) { + return; + } + tickInFlight = true; + try { + await params.onTick(); + } finally { + tickInFlight = false; + } + }; + + const start = () => { + if (params.intervalMs <= 0 || timer) { + return; + } + timer = setInterval(() => { + void tick(); + }, params.intervalMs); + }; + + const stop = () => { + if (!timer) { + return; + } + clearInterval(timer); + timer = undefined; + tickInFlight = false; + }; + + const isRunning = () => timer !== undefined; + + return { + tick, + start, + stop, + isRunning, + }; +} diff --git a/src/channels/typing.test.ts b/src/channels/typing.test.ts new file mode 100644 index 000000000000..c1f314183b84 --- /dev/null +++ b/src/channels/typing.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTypingCallbacks } from "./typing.js"; + +const flushMicrotasks = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +describe("createTypingCallbacks", () => { + it("invokes start on reply start", async () => { + const start = vi.fn().mockResolvedValue(undefined); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, onStartError }); + + await callbacks.onReplyStart(); + + expect(start).toHaveBeenCalledTimes(1); + expect(onStartError).not.toHaveBeenCalled(); + }); + + it("reports start errors", async () => { + const start = vi.fn().mockRejectedValue(new Error("fail")); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, onStartError }); + + await callbacks.onReplyStart(); + + expect(onStartError).toHaveBeenCalledTimes(1); + }); + + it("invokes stop on idle and reports stop errors", async () => { + const start = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockRejectedValue(new Error("stop")); + const onStartError = vi.fn(); + const onStopError = vi.fn(); + const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError }); + + callbacks.onIdle?.(); + await flushMicrotasks(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(onStopError).toHaveBeenCalledTimes(1); + }); + + it("sends typing keepalive pings until idle cleanup", async () => { + vi.useFakeTimers(); + try { + const start = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockResolvedValue(undefined); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, stop, onStartError }); + + await callbacks.onReplyStart(); + expect(start).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(2_999); + expect(start).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + expect(start).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(3_000); + expect(start).toHaveBeenCalledTimes(3); + + callbacks.onIdle?.(); + await flushMicrotasks(); + expect(stop).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(9_000); + expect(start).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + + it("deduplicates stop across idle and cleanup", async () => { + const start = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockResolvedValue(undefined); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, stop, onStartError }); + + callbacks.onIdle?.(); + callbacks.onCleanup?.(); + await flushMicrotasks(); + + expect(stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/channels/typing.ts b/src/channels/typing.ts index f6d60d498d1e..b701dfb72cd7 100644 --- a/src/channels/typing.ts +++ b/src/channels/typing.ts @@ -1,3 +1,5 @@ +import { createTypingKeepaliveLoop } from "./typing-lifecycle.js"; + export type TypingCallbacks = { onReplyStart: () => Promise; onIdle?: () => void; @@ -14,8 +16,6 @@ export function createTypingCallbacks(params: { }): TypingCallbacks { const stop = params.stop; const keepaliveIntervalMs = params.keepaliveIntervalMs ?? 3_000; - let keepaliveTimer: ReturnType | undefined; - let keepaliveStartInFlight = false; let stopSent = false; const fireStart = async () => { @@ -26,35 +26,20 @@ export function createTypingCallbacks(params: { } }; - const clearKeepalive = () => { - if (!keepaliveTimer) { - return; - } - clearInterval(keepaliveTimer); - keepaliveTimer = undefined; - keepaliveStartInFlight = false; - }; + const keepaliveLoop = createTypingKeepaliveLoop({ + intervalMs: keepaliveIntervalMs, + onTick: fireStart, + }); const onReplyStart = async () => { stopSent = false; - clearKeepalive(); + keepaliveLoop.stop(); await fireStart(); - if (keepaliveIntervalMs <= 0) { - return; - } - keepaliveTimer = setInterval(() => { - if (keepaliveStartInFlight) { - return; - } - keepaliveStartInFlight = true; - void fireStart().finally(() => { - keepaliveStartInFlight = false; - }); - }, keepaliveIntervalMs); + keepaliveLoop.start(); }; const fireStop = () => { - clearKeepalive(); + keepaliveLoop.stop(); if (!stop || stopSent) { return; } diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 1d84cd01410c..00124709c740 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -569,6 +569,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: resolveHumanDelayConfig(cfg, route.agentId), + typingCallbacks, deliver: async (payload: ReplyPayload, info) => { const isFinal = info.kind === "final"; if (payload.isReasoning) { @@ -669,8 +670,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) await typingCallbacks.onReplyStart(); await statusReactions.setThinking(); }, - onIdle: typingCallbacks.onIdle, - onCleanup: typingCallbacks.onCleanup, }); let dispatchResult: Awaited> | null = null; diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 4133930389a1..b095626ab460 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -222,6 +222,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId), + typingCallbacks, deliver: async (payload) => { await deps.deliverReplies({ replies: [payload], @@ -237,9 +238,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { onError: (err, info) => { deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`)); }, - onReplyStart: typingCallbacks.onReplyStart, - onIdle: typingCallbacks.onIdle, - onCleanup: typingCallbacks.onCleanup, }); const { queuedFinal } = await dispatchInboundMessage({ diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index f6d4b531f61b..35db7c2f70ea 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -243,6 +243,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: resolveHumanDelayConfig(cfg, route.agentId), + typingCallbacks, deliver: async (payload) => { if (useStreaming) { await deliverWithStreaming(payload); @@ -304,9 +305,6 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag runtime.error?.(danger(`slack ${info.kind} reply failed: ${String(err)}`)); typingCallbacks.onIdle?.(); }, - onReplyStart: typingCallbacks.onReplyStart, - onIdle: typingCallbacks.onIdle, - onCleanup: typingCallbacks.onCleanup, }); const draftStream = createSlackDraftStream({ diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 89cb59038db3..f45b79fb9abd 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -436,6 +436,7 @@ export const dispatchTelegramMessage = async ({ cfg, dispatcherOptions: { ...prefixOptions, + typingCallbacks, deliver: async (payload, info) => { const previewButtons = ( payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined @@ -540,9 +541,6 @@ export const dispatchTelegramMessage = async ({ deliveryState.markNonSilentFailure(); runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); }, - onReplyStart: typingCallbacks.onReplyStart, - onIdle: typingCallbacks.onIdle, - onCleanup: typingCallbacks.onCleanup, }, replyOptions: { skillFilter, From 45b5c35b215f61985a825a7b33df7baf8bc0bd75 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:28:42 +0000 Subject: [PATCH 358/408] test: fix CI failures in heartbeat and typing tests --- ...i-embedded-runner-extraparams.live.test.ts | 5 +--- src/auto-reply/reply/reply-utils.test.ts | 18 ++++++------- ...espects-ackmaxchars-heartbeat-acks.test.ts | 25 +++++++++++-------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/agents/pi-embedded-runner-extraparams.live.test.ts b/src/agents/pi-embedded-runner-extraparams.live.test.ts index 8da5bef6f570..4116476c71f2 100644 --- a/src/agents/pi-embedded-runner-extraparams.live.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.live.test.ts @@ -153,10 +153,7 @@ describeGeminiLive("pi embedded extra params (gemini live)", () => { } it("sanitizes Gemini 3.1 thinking payload and keeps image parts with reasoning enabled", async () => { - const model = getModel( - "google", - "gemini-3.1-pro-preview", - ) as unknown as Model<"google-generative-ai">; + const model = getModel("google", "gemini-2.5-pro") as unknown as Model<"google-generative-ai">; const agent = { streamFn: streamSimple }; applyExtraParamsToAgent(agent, undefined, "google", model.id, undefined, "high"); diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index 4262b80db0f4..ef5a3a733b05 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -123,7 +123,7 @@ describe("typing controller", () => { ] as const; for (const testCase of cases) { - const onReplyStart = vi.fn(async () => {}); + const onReplyStart = vi.fn(); const typing = createTypingController({ onReplyStart, typingIntervalSeconds: 1, @@ -133,7 +133,7 @@ describe("typing controller", () => { await typing.startTypingLoop(); expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(1); - vi.advanceTimersByTime(2_000); + await vi.advanceTimersByTimeAsync(2_000); expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(3); if (testCase.first === "run") { @@ -141,7 +141,7 @@ describe("typing controller", () => { } else { typing.markDispatchIdle(); } - vi.advanceTimersByTime(2_000); + await vi.advanceTimersByTimeAsync(2_000); expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(5); if (testCase.second === "run") { @@ -149,14 +149,14 @@ describe("typing controller", () => { } else { typing.markDispatchIdle(); } - vi.advanceTimersByTime(2_000); + await vi.advanceTimersByTimeAsync(2_000); expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(5); } }); it("does not start typing after run completion", async () => { vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); + const onReplyStart = vi.fn(); const typing = createTypingController({ onReplyStart, typingIntervalSeconds: 1, @@ -165,13 +165,13 @@ describe("typing controller", () => { typing.markRunComplete(); await typing.startTypingOnText("late text"); - vi.advanceTimersByTime(2_000); + await vi.advanceTimersByTimeAsync(2_000); expect(onReplyStart).not.toHaveBeenCalled(); }); it("does not restart typing after it has stopped", async () => { vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); + const onReplyStart = vi.fn(); const typing = createTypingController({ onReplyStart, typingIntervalSeconds: 1, @@ -184,12 +184,12 @@ describe("typing controller", () => { typing.markRunComplete(); typing.markDispatchIdle(); - vi.advanceTimersByTime(5_000); + await vi.advanceTimersByTimeAsync(5_000); expect(onReplyStart).toHaveBeenCalledTimes(1); // Late callbacks should be ignored and must not restart the interval. await typing.startTypingOnText("late tool result"); - vi.advanceTimersByTime(5_000); + await vi.advanceTimersByTimeAsync(5_000); expect(onReplyStart).toHaveBeenCalledTimes(1); }); }); diff --git a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts index 926e5292a0d7..d0f4fd19bd7e 100644 --- a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts +++ b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts @@ -15,6 +15,9 @@ vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); installHeartbeatRunnerTestRuntime(); describe("runHeartbeatOnce ack handling", () => { + const WHATSAPP_GROUP = "120363140186826074@g.us"; + const TELEGRAM_GROUP = "-1001234567890"; + function createHeartbeatConfig(params: { tmpDir: string; storePath: string; @@ -105,7 +108,7 @@ describe("runHeartbeatOnce ack handling", () => { await seedMainSessionStore(params.storePath, cfg, { lastChannel: "telegram", lastProvider: "telegram", - lastTo: "12345", + lastTo: TELEGRAM_GROUP, }); params.replySpy.mockResolvedValue({ text: params.replyText }); @@ -150,7 +153,7 @@ describe("runHeartbeatOnce ack handling", () => { await seedMainSessionStore(params.storePath, cfg, { lastChannel: "whatsapp", lastProvider: "whatsapp", - lastTo: "+1555", + lastTo: WHATSAPP_GROUP, }); return cfg; } @@ -166,7 +169,7 @@ describe("runHeartbeatOnce ack handling", () => { await seedMainSessionStore(storePath, cfg, { lastChannel: "whatsapp", lastProvider: "whatsapp", - lastTo: "+1555", + lastTo: WHATSAPP_GROUP, }); replySpy.mockResolvedValue({ text: "HEARTBEAT_OK 🦞" }); @@ -192,7 +195,7 @@ describe("runHeartbeatOnce ack handling", () => { await seedMainSessionStore(storePath, cfg, { lastChannel: "whatsapp", lastProvider: "whatsapp", - lastTo: "+1555", + lastTo: WHATSAPP_GROUP, }); replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); @@ -204,7 +207,7 @@ describe("runHeartbeatOnce ack handling", () => { }); expect(sendWhatsApp).toHaveBeenCalledTimes(1); - expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "HEARTBEAT_OK", expect.any(Object)); + expect(sendWhatsApp).toHaveBeenCalledWith(WHATSAPP_GROUP, "HEARTBEAT_OK", expect.any(Object)); }); }); @@ -239,7 +242,7 @@ describe("runHeartbeatOnce ack handling", () => { expect(sendTelegram).toHaveBeenCalledTimes(expectedCalls); if (expectedText) { - expect(sendTelegram).toHaveBeenCalledWith("12345", expectedText, expect.any(Object)); + expect(sendTelegram).toHaveBeenCalledWith(TELEGRAM_GROUP, expectedText, expect.any(Object)); } }); }); @@ -255,7 +258,7 @@ describe("runHeartbeatOnce ack handling", () => { await seedMainSessionStore(storePath, cfg, { lastChannel: "whatsapp", lastProvider: "whatsapp", - lastTo: "+1555", + lastTo: WHATSAPP_GROUP, }); const sendWhatsApp = createMessageSendSpy(); @@ -303,7 +306,7 @@ describe("runHeartbeatOnce ack handling", () => { updatedAt: originalUpdatedAt, lastChannel: "whatsapp", lastProvider: "whatsapp", - lastTo: "+1555", + lastTo: WHATSAPP_GROUP, }); replySpy.mockImplementationOnce(async () => { @@ -372,11 +375,11 @@ describe("runHeartbeatOnce ack handling", () => { await seedMainSessionStore(storePath, cfg, { lastChannel: "telegram", lastProvider: "telegram", - lastTo: "123456", + lastTo: TELEGRAM_GROUP, }); replySpy.mockResolvedValue({ text: "Hello from heartbeat" }); - const sendTelegram = createMessageSendSpy({ chatId: "123456" }); + const sendTelegram = createMessageSendSpy({ chatId: TELEGRAM_GROUP }); await runHeartbeatOnce({ cfg, @@ -385,7 +388,7 @@ describe("runHeartbeatOnce ack handling", () => { expect(sendTelegram).toHaveBeenCalledTimes(1); expect(sendTelegram).toHaveBeenCalledWith( - "123456", + TELEGRAM_GROUP, "Hello from heartbeat", expect.objectContaining({ accountId: params.expectedAccountId, verbose: false }), ); From 9f1bda9802235cf28a0a0939af6fd272d42e724f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:32:35 +0000 Subject: [PATCH 359/408] test: fix TS2742 in telegram media test utils --- src/telegram/bot.media.test-utils.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/telegram/bot.media.test-utils.ts b/src/telegram/bot.media.test-utils.ts index 4d49eda3f60a..94084bad31c4 100644 --- a/src/telegram/bot.media.test-utils.ts +++ b/src/telegram/bot.media.test-utils.ts @@ -1,10 +1,12 @@ -import { afterEach, beforeAll, beforeEach, expect, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, expect, vi, type Mock } from "vitest"; import * as ssrf from "../infra/net/ssrf.js"; import { onSpy, sendChatActionSpy } from "./bot.media.e2e-harness.js"; -export const cacheStickerSpy = vi.fn(); -export const getCachedStickerSpy = vi.fn(); -export const describeStickerImageSpy = vi.fn(); +type StickerSpy = Mock<(...args: unknown[]) => unknown>; + +export const cacheStickerSpy: StickerSpy = vi.fn(); +export const getCachedStickerSpy: StickerSpy = vi.fn(); +export const describeStickerImageSpy: StickerSpy = vi.fn(); const resolvePinnedHostname = ssrf.resolvePinnedHostname; const lookupMock = vi.fn(); From 2d1e6931a670bf99550b16998bee6b6c3f17e373 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:32:55 +0000 Subject: [PATCH 360/408] docs(changelog): reorder and backfill 2026.2.24 release notes --- CHANGELOG.md | 111 ++++++++++++++++++++++++++------------------------- 1 file changed, 57 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f05b11ae77d5..b3af54500f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,60 +6,84 @@ Docs: https://docs.openclaw.ai ### Changes -- Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping `@buape/carbon` pinned. - Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms), and treat exact `do not do that` as a stop trigger while preserving strict standalone matching. (#25103) Thanks @steipete and @vincentkoc. +- Android/App UX: ship a native four-step onboarding flow, move post-onboarding into a five-tab shell (Connect, Chat, Voice, Screen, Settings), add a full Connect setup/manual mode screen, and refresh Android chat/settings surfaces for the new navigation model. +- Talk/Gateway config: add provider-agnostic Talk configuration with legacy compatibility, and expose gateway Talk ElevenLabs config metadata for setup/status surfaces. - Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes). +- Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping `@buape/carbon` pinned. ### Breaking -- **BREAKING:** Security/Sandbox: block Docker `network: "container:"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting. - **BREAKING:** Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages. +- **BREAKING:** Security/Sandbox: block Docker `network: "container:"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting. ### Fixes +- Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. +- Security/Routing: fail closed for shared-session cross-channel replies by binding outbound target resolution to the current turn’s source channel metadata (instead of stale session route fallbacks), and wire those turn-source fields through gateway + command delivery planners with regression coverage. (#24571) Thanks @brandonwise. - Heartbeat routing: prevent heartbeat leakage/spam into Discord and other direct-message destinations by blocking direct-chat heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871) -- iMessage/Reasoning safety: harden iMessage echo suppression with outbound `messageId` matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb. -- Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 `dev=0` stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false `Local media path is not safe to read` drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng. -- Windows/Exec shell selection: prefer PowerShell 7 (`pwsh`) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing `&&` command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x. -- macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos. +- Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from `last` to `none` (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851) +- Auto-reply/Heartbeat queueing: drop heartbeat runs when a session already has an active run instead of enqueueing a stale followup, preventing duplicate heartbeat response branches after queue drain. (#25610, #25606) Thanks @mcaxtr. +- Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. +- Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch. +- Channels/Typing keepalive: refresh channel typing callbacks on a keepalive interval during long replies and clear keepalive timers on idle/cleanup across core + extension dispatcher callsites so typing indicators do not expire mid-inference. (#25886, #25882) Thanks @stakeswky. +- Agents/Model fallback: when a run is currently on a configured fallback model, keep traversing the configured fallback chain instead of collapsing straight to primary-only, preventing dead-end failures when primary stays in cooldown. (#25922, #25912) Thanks @Taskle. +- Gateway/Models: honor explicit `agents.defaults.models` allowlist refs even when bundled model catalog data is stale, synthesize missing allowlist entries in `models.list`, and allow `sessions.patch`/`/model` selection for those refs without false `model not allowed` errors. (#20291) Thanks @kensipe, @nikolasdehor, and @vincentkoc. +- Control UI/Agents: inherit `agents.defaults.model.fallbacks` in the Overview fallback input when no per-agent model entry exists, while preserving explicit per-agent fallback overrides (including empty lists). (#25729, #25710) Thanks @Suko. +- Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky. +- Discord/Voice reliability: restore runtime DAVE dependency (`@snazzah/davey`), add configurable DAVE join options (`channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance`), clean up voice listeners/session teardown, guard against stale connection events, and trigger controlled rejoin recovery after repeated decrypt failures to improve inbound STT stability under DAVE receive errors. (#25861, #25372, #24883, #24825, #23890, #23105, #22961, #23421, #23278, #23032) +- Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin. +- Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire `messages.statusReactions.{emojis,timing}` into Discord reaction lifecycle control, and compact model-picker `custom_id` keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr. +- WhatsApp/Web reconnect: treat close status `440` as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson. +- WhatsApp/Reasoning safety: suppress outbound payloads marked as reasoning and hard-drop text payloads that begin with `Reasoning:` before WhatsApp delivery, preventing hidden thinking blocks from leaking to end users through final-message paths. (#25804, #25214, #24328) +- Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall. +- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. +- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. +- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis. +- Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko. +- Android/Gateway auth: preserve Android gateway auth state across onboarding, use the native client id for operator sessions, retry with shared-token fallback after device-token auth failures, and avoid clearing tokens on transient connect errors. +- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr. +- Zalo/Group policy: enforce sender authorization for group messages with `groupPolicy` + `groupAllowFrom` (fallback to `allowFrom`), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. Thanks @tdjackey for reporting. +- macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001. - macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl. - macOS/Voice wake routing: default forwarded voice-wake transcripts to the `webchat` channel (instead of ambiguous `last` routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18. - macOS/Gateway launch: prefer an available `openclaw` binary before pnpm/node runtime fallback when resolving local gateway commands, so local startup no longer fails on hosts with broken runtime discovery. (#25512) Thanks @chilu18. -- macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001. -- Gateway/Security: enforce gateway auth for the exact `/api/channels` plugin root path (plus `/api/channels/` descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3. -- Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (`LD_*`, `DYLD_*`, `SSLKEYLOGFILE`, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3. -- Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width `HOOK:...`) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3. -- Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3. +- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. +- macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos. +- Windows/Exec shell selection: prefer PowerShell 7 (`pwsh`) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing `&&` command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x. +- Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 `dev=0` stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false `Local media path is not safe to read` drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng. +- iMessage/Reasoning safety: harden iMessage echo suppression with outbound `messageId` matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb. - Providers/OpenRouter/Auth profiles: bypass auth-profile cooldown/disable windows for OpenRouter, so provider failures no longer put OpenRouter profiles into local cooldown and stale legacy cooldown markers are ignored in fallback and status selection paths. (#25892) Thanks @alexanderatallah for raising this and @vincentkoc for the fix. - Providers/Google reasoning: sanitize invalid negative `thinkingBudget` payloads for Gemini 3.1 requests by dropping `-1` budgets and mapping configured reasoning effort to `thinkingLevel`, preventing malformed reasoning payloads on `google-generative-ai`. (#25900) -- WhatsApp/Web reconnect: treat close status `440` as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson. -- WhatsApp/Reasoning safety: suppress outbound payloads marked as reasoning and hard-drop text payloads that begin with `Reasoning:` before WhatsApp delivery, preventing hidden thinking blocks from leaking to end users through final-message paths. (#25804, #25214, #24328) -- Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko. -- Models/Bedrock auth: normalize additional Bedrock provider aliases (`bedrock`, `aws-bedrock`, `aws_bedrock`, `amazon bedrock`) to canonical `amazon-bedrock`, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13. - Providers/SiliconFlow: normalize `thinking="off"` to `thinking: null` for `Pro/*` model payloads to avoid provider-side 400 loops and misleading compaction retries. (#25435) Thanks @Zjianru. -- Gateway/Models: honor explicit `agents.defaults.models` allowlist refs even when bundled model catalog data is stale, synthesize missing allowlist entries in `models.list`, and allow `sessions.patch`/`/model` selection for those refs without false `model not allowed` errors. (#20291) Thanks @kensipe, @nikolasdehor, and @vincentkoc. -- Agents/Model fallback: when a run is currently on a configured fallback model, keep traversing the configured fallback chain instead of collapsing straight to primary-only, preventing dead-end failures when primary stays in cooldown. (#25922, #25912) Thanks @Taskle. -- Control UI/Agents: inherit `agents.defaults.model.fallbacks` in the Overview fallback input when no per-agent model entry exists, while preserving explicit per-agent fallback overrides (including empty lists). (#25729, #25710) Thanks @Suko. -- Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky. -- Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire `messages.statusReactions.{emojis,timing}` into Discord reaction lifecycle control, and compact model-picker `custom_id` keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr. -- Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin. -- Discord/Voice reliability: restore runtime DAVE dependency (`@snazzah/davey`), add configurable DAVE join options (`channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance`), clean up voice listeners/session teardown, guard against stale connection events, and trigger controlled rejoin recovery after repeated decrypt failures to improve inbound STT stability under DAVE receive errors. (#25861, #25372, #24883, #24825, #23890, #23105, #22961, #23421, #23278, #23032) -- Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall. -- Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility. +- Models/Bedrock auth: normalize additional Bedrock provider aliases (`bedrock`, `aws-bedrock`, `aws_bedrock`, `amazon bedrock`) to canonical `amazon-bedrock`, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13. +- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728. +- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. +- CLI/Memory search: accept `--query ` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky. +- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. +- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. +- Doctor/Plugins: auto-enable now resolves third-party channel plugins by manifest plugin id (not channel id), preventing invalid `plugins.entries.` writes when ids differ. (#25275) Thanks @zerone0x. +- Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. +- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr. +- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. +- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. +- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis. +- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. +- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18. +- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi. - Sandbox/FS bridge tests: add regression coverage for dash-leading basenames to confirm sandbox file reads resolve to absolute container paths (and avoid shell-option misdiagnosis for dashed filenames). (#25891) Thanks @albertlieyingadrian. +- Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility. - Sandbox/Config: preserve `dangerouslyAllowReservedContainerTargets` and `dangerouslyAllowExternalBindSources` during sandbox docker config resolution so explicit bind-mount break-glass overrides reach runtime validation. (#25410) Thanks @skyer-jian. -- Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. -- Security/Routing: fail closed for shared-session cross-channel replies by binding outbound target resolution to the current turn’s source channel metadata (instead of stale session route fallbacks), and wire those turn-source fields through gateway + command delivery planners with regression coverage. (#24571) Thanks @brandonwise. -- Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch. -- Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. -- Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from `last` to `none` (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851) -- Auto-reply/Heartbeat queueing: drop heartbeat runs when a session already has an active run instead of enqueueing a stale followup, preventing duplicate heartbeat response branches after queue drain. (#25610, #25606) Thanks @mcaxtr. -- Channels/Typing keepalive: refresh channel typing callbacks on a keepalive interval during long replies and clear keepalive timers on idle/cleanup across core + extension dispatcher callsites so typing indicators do not expire mid-inference. (#25886, #25882) Thanks @stakeswky. +- Gateway/Security: enforce gateway auth for the exact `/api/channels` plugin root path (plus `/api/channels/` descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3. +- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber. +- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach. +- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd. +- Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (`LD_*`, `DYLD_*`, `SSLKEYLOGFILE`, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3. +- Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width `HOOK:...`) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3. +- Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3. - Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting. - Security/Sandbox media: reject hard-linked OpenClaw tmp media aliases (including symlink-to-hardlink chains) during sandbox media path resolution to prevent out-of-sandbox inode alias reads. (#25820) Thanks @bmendonca3. -- Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. - Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. Thanks @GCXWLP for reporting. -- Zalo/Group policy: enforce sender authorization for group messages with `groupPolicy` + `groupAllowFrom` (fallback to `allowFrom`), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. Thanks @tdjackey for reporting. - Security/Telegram: enforce DM authorization before media download/write (including media groups) and move telegram inbound activity tracking after DM authorization, preventing unauthorized sender-triggered inbound media disk writes. Thanks @v8hid for reporting. - Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. Thanks @tdjackey for reporting. - Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so `dmPolicy: "allowlist"` with empty `allowedUserIds` rejects all senders instead of allowing unauthorized dispatch. (#25827) Thanks @bmendonca3 for the contribution and @tdjackey for reporting. @@ -68,28 +92,7 @@ Docs: https://docs.openclaw.ai - Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting. - Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. Thanks @tdjackey for reporting. - Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. Thanks @tdjackey for reporting. -- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. -- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis. -- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis. -- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. -- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. -- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. -- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. -- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr. -- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18. -- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi. -- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr. -- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728. -- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber. -- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. -- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. -- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach. -- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. -- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd. -- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. -- CLI/Memory search: accept `--query ` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky. - Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey. -- Doctor/Plugins: auto-enable now resolves third-party channel plugins by manifest plugin id (not channel id), preventing invalid `plugins.entries.` writes when ids differ. (#25275) Thanks @zerone0x. ## 2026.2.23 From a12cbf8994b8e92ae393d386d9b9a8f368dbb5e5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:40:05 +0000 Subject: [PATCH 361/408] docs: refresh CLI and trusted-proxy docs --- docs/cli/devices.md | 21 +++++++++++++++++++++ docs/cli/index.md | 7 ++++--- docs/cli/memory.md | 7 +++++++ docs/cli/pairing.md | 15 +++++++++++++-- docs/gateway/trusted-proxy-auth.md | 12 ++++++++++++ docs/tools/exec.md | 2 ++ 6 files changed, 59 insertions(+), 5 deletions(-) diff --git a/docs/cli/devices.md b/docs/cli/devices.md index edacf9a2876e..be01e3cc0d52 100644 --- a/docs/cli/devices.md +++ b/docs/cli/devices.md @@ -21,6 +21,25 @@ openclaw devices list openclaw devices list --json ``` +### `openclaw devices remove ` + +Remove one paired device entry. + +``` +openclaw devices remove +openclaw devices remove --json +``` + +### `openclaw devices clear --yes [--pending]` + +Clear paired devices in bulk. + +``` +openclaw devices clear --yes +openclaw devices clear --yes --pending +openclaw devices clear --yes --pending --json +``` + ### `openclaw devices approve [requestId] [--latest]` Approve a pending device pairing request. If `requestId` is omitted, OpenClaw @@ -71,3 +90,5 @@ Pass `--token` or `--password` explicitly. Missing explicit credentials is an er - Token rotation returns a new token (sensitive). Treat it like a secret. - These commands require `operator.pairing` (or `operator.admin`) scope. +- `devices clear` is intentionally gated by `--yes`. +- If pairing scope is unavailable on local loopback (and no explicit `--url` is passed), list/approve can use a local pairing fallback. diff --git a/docs/cli/index.md b/docs/cli/index.md index 49017c3735df..0a9878c23da3 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -281,7 +281,7 @@ Vector search over `MEMORY.md` + `memory/*.md`: - `openclaw memory status` — show index stats. - `openclaw memory index` — reindex memory files. -- `openclaw memory search ""` — semantic search over memory. +- `openclaw memory search ""` (or `--query ""`) — semantic search over memory. ## Chat slash commands @@ -468,8 +468,9 @@ Approve DM pairing requests across channels. Subcommands: -- `pairing list [--json]` -- `pairing approve [--notify]` +- `pairing list [channel] [--channel ] [--account ] [--json]` +- `pairing approve [--account ] [--notify]` +- `pairing approve --channel [--account ] [--notify]` ### `webhooks gmail` diff --git a/docs/cli/memory.md b/docs/cli/memory.md index bc6d05c12e3c..11b9926c56a7 100644 --- a/docs/cli/memory.md +++ b/docs/cli/memory.md @@ -26,6 +26,7 @@ openclaw memory status --deep --index --verbose openclaw memory index openclaw memory index --verbose openclaw memory search "release checklist" +openclaw memory search --query "release checklist" openclaw memory status --agent main openclaw memory index --agent main --verbose ``` @@ -37,6 +38,12 @@ Common: - `--agent `: scope to a single agent (default: all configured agents). - `--verbose`: emit detailed logs during probes and indexing. +`memory search`: + +- Query input: pass either positional `[query]` or `--query `. +- If both are provided, `--query` wins. +- If neither is provided, the command exits with an error. + Notes: - `memory status --deep` probes vector + embedding availability. diff --git a/docs/cli/pairing.md b/docs/cli/pairing.md index 319ddc29a0f6..13ad8a59948b 100644 --- a/docs/cli/pairing.md +++ b/docs/cli/pairing.md @@ -16,6 +16,17 @@ Related: ## Commands ```bash -openclaw pairing list whatsapp -openclaw pairing approve whatsapp --notify +openclaw pairing list telegram +openclaw pairing list --channel telegram --account work +openclaw pairing list telegram --json + +openclaw pairing approve telegram +openclaw pairing approve --channel telegram --account work --notify ``` + +## Notes + +- Channel input: pass it positionally (`pairing list telegram`) or with `--channel `. +- `pairing list` supports `--account ` for multi-account channels. +- `pairing approve` supports `--account ` and `--notify`. +- If only one pairing-capable channel is configured, `pairing approve ` is allowed. diff --git a/docs/gateway/trusted-proxy-auth.md b/docs/gateway/trusted-proxy-auth.md index 2b30b234e242..7144452b2e6c 100644 --- a/docs/gateway/trusted-proxy-auth.md +++ b/docs/gateway/trusted-proxy-auth.md @@ -35,6 +35,18 @@ Use `trusted-proxy` auth mode when: 4. OpenClaw extracts the user identity from the configured header 5. If everything checks out, the request is authorized +## Control UI Pairing Behavior + +When `gateway.auth.mode = "trusted-proxy"` is active and the request passes +trusted-proxy checks, Control UI WebSocket sessions can connect without device +pairing identity. + +Implications: + +- Pairing is no longer the primary gate for Control UI access in this mode. +- Your reverse proxy auth policy and `allowUsers` become the effective access control. +- Keep gateway ingress locked to trusted proxy IPs only (`gateway.trustedProxies` + firewall). + ## Configuration ```json5 diff --git a/docs/tools/exec.md b/docs/tools/exec.md index a52af45fdcbd..822717fcf382 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -36,6 +36,8 @@ Notes: - If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one. - On non-Windows hosts, exec uses `SHELL` when set; if `SHELL` is `fish`, it prefers `bash` (or `sh`) from `PATH` to avoid fish-incompatible scripts, then falls back to `SHELL` if neither exists. +- On Windows hosts, exec prefers PowerShell 7 (`pwsh`) discovery (Program Files, ProgramW6432, then PATH), + then falls back to Windows PowerShell 5.1. - Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to prevent binary hijacking or injected code. - Important: sandboxing is **off by default**. If sandboxing is off and `host=sandbox` is explicitly From bfafec2271e29a56a500cd3b76220377bc62c31c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:41:13 +0000 Subject: [PATCH 362/408] docs: expand doctor and devices CLI references --- docs/cli/doctor.md | 2 ++ docs/cli/index.md | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index dff899d7cd28..d53d86452f3b 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -28,6 +28,8 @@ Notes: - Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts. - `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal. - State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.` to reclaim space safely. +- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing. +- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`). ## macOS: `launchctl` env overrides diff --git a/docs/cli/index.md b/docs/cli/index.md index 0a9878c23da3..32eb31b5eb3d 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -472,6 +472,20 @@ Subcommands: - `pairing approve [--account ] [--notify]` - `pairing approve --channel [--account ] [--notify]` +### `devices` + +Manage gateway device pairing entries and per-role device tokens. + +Subcommands: + +- `devices list [--json]` +- `devices approve [requestId] [--latest]` +- `devices reject ` +- `devices remove ` +- `devices clear --yes [--pending]` +- `devices rotate --device --role [--scope ]` +- `devices revoke --device --role ` + ### `webhooks gmail` Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub). From c2a837565c6ef4a374cf9579ab4bb430ef2ee722 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:44:49 +0000 Subject: [PATCH 363/408] docs: fix configure section example --- docs/cli/configure.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/configure.md b/docs/cli/configure.md index 1590a0550501..0055abec7b49 100644 --- a/docs/cli/configure.md +++ b/docs/cli/configure.md @@ -29,5 +29,5 @@ Notes: ```bash openclaw configure -openclaw configure --section models --section channels +openclaw configure --section model --section channels ``` From df9a474891d48084a452a2f809fb239dc751c323 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:46:15 +0000 Subject: [PATCH 364/408] test: stabilize no-output timeout exec test --- src/process/exec.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index d5da9b0a0b7e..67c443cb2e29 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -101,16 +101,16 @@ describe("runCommandWithTimeout", () => { "let count = 0;", 'const ticker = setInterval(() => { process.stdout.write(".");', "count += 1;", - "if (count === 2) {", + "if (count === 6) {", "clearInterval(ticker);", "process.exit(0);", "}", - "}, 12);", + "}, 200);", ].join(" "), ], { - timeoutMs: 5_000, - noOutputTimeoutMs: 120, + timeoutMs: 7_000, + noOutputTimeoutMs: 450, }, ); @@ -118,7 +118,7 @@ describe("runCommandWithTimeout", () => { expect(result.code ?? 0).toBe(0); expect(result.termination).toBe("exit"); expect(result.noOutputTimedOut).toBe(false); - expect(result.stdout.length).toBeGreaterThanOrEqual(3); + expect(result.stdout.length).toBeGreaterThanOrEqual(7); }); it("reports global timeout termination when overall timeout elapses", async () => { From 7c59b78aeedd1dd2b608bf7df9493e8339e02f03 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:48:25 +0000 Subject: [PATCH 365/408] test: cap docker live model sweeps and harden timeouts --- scripts/test-live-gateway-models-docker.sh | 3 +- scripts/test-live-models-docker.sh | 3 +- src/agents/models.profiles.live.test.ts | 99 +++++++++++++++++-- .../gateway-models.profiles.live.test.ts | 69 ++++++++++++- 4 files changed, 159 insertions(+), 15 deletions(-) diff --git a/scripts/test-live-gateway-models-docker.sh b/scripts/test-live-gateway-models-docker.sh index bb0641df16b9..3cc5ed2bf0b6 100755 --- a/scripts/test-live-gateway-models-docker.sh +++ b/scripts/test-live-gateway-models-docker.sh @@ -22,8 +22,9 @@ docker run --rm -t \ -e HOME=/home/node \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_LIVE_TEST=1 \ - -e OPENCLAW_LIVE_GATEWAY_MODELS="${OPENCLAW_LIVE_GATEWAY_MODELS:-${CLAWDBOT_LIVE_GATEWAY_MODELS:-all}}" \ + -e OPENCLAW_LIVE_GATEWAY_MODELS="${OPENCLAW_LIVE_GATEWAY_MODELS:-${CLAWDBOT_LIVE_GATEWAY_MODELS:-modern}}" \ -e OPENCLAW_LIVE_GATEWAY_PROVIDERS="${OPENCLAW_LIVE_GATEWAY_PROVIDERS:-${CLAWDBOT_LIVE_GATEWAY_PROVIDERS:-}}" \ + -e OPENCLAW_LIVE_GATEWAY_MAX_MODELS="${OPENCLAW_LIVE_GATEWAY_MAX_MODELS:-${CLAWDBOT_LIVE_GATEWAY_MAX_MODELS:-24}}" \ -e OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS="${OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS:-${CLAWDBOT_LIVE_GATEWAY_MODEL_TIMEOUT_MS:-}}" \ -v "$CONFIG_DIR":/home/node/.openclaw \ -v "$WORKSPACE_DIR":/home/node/.openclaw/workspace \ diff --git a/scripts/test-live-models-docker.sh b/scripts/test-live-models-docker.sh index 1a7df857c7ae..f3aecc0049a9 100755 --- a/scripts/test-live-models-docker.sh +++ b/scripts/test-live-models-docker.sh @@ -22,8 +22,9 @@ docker run --rm -t \ -e HOME=/home/node \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_LIVE_TEST=1 \ - -e OPENCLAW_LIVE_MODELS="${OPENCLAW_LIVE_MODELS:-${CLAWDBOT_LIVE_MODELS:-all}}" \ + -e OPENCLAW_LIVE_MODELS="${OPENCLAW_LIVE_MODELS:-${CLAWDBOT_LIVE_MODELS:-modern}}" \ -e OPENCLAW_LIVE_PROVIDERS="${OPENCLAW_LIVE_PROVIDERS:-${CLAWDBOT_LIVE_PROVIDERS:-}}" \ + -e OPENCLAW_LIVE_MAX_MODELS="${OPENCLAW_LIVE_MAX_MODELS:-${CLAWDBOT_LIVE_MAX_MODELS:-48}}" \ -e OPENCLAW_LIVE_MODEL_TIMEOUT_MS="${OPENCLAW_LIVE_MODEL_TIMEOUT_MS:-${CLAWDBOT_LIVE_MODEL_TIMEOUT_MS:-}}" \ -e OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS="${OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS:-${CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS:-}}" \ -v "$CONFIG_DIR":/home/node/.openclaw \ diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts index d56986b8038c..2db27d076719 100644 --- a/src/agents/models.profiles.live.test.ts +++ b/src/agents/models.profiles.live.test.ts @@ -91,6 +91,10 @@ function isInstructionsRequiredError(raw: string): boolean { return /instructions are required/i.test(raw); } +function isModelTimeoutError(raw: string): boolean { + return /model call timed out after \d+ms/i.test(raw); +} + function toInt(value: string | undefined, fallback: number): number { const trimmed = value?.trim(); if (!trimmed) { @@ -100,6 +104,49 @@ function toInt(value: string | undefined, fallback: number): number { return Number.isFinite(parsed) ? parsed : fallback; } +function capByProviderSpread( + items: T[], + maxItems: number, + providerOf: (item: T) => string, +): T[] { + if (maxItems <= 0 || items.length <= maxItems) { + return items; + } + const providerOrder: string[] = []; + const grouped = new Map(); + for (const item of items) { + const provider = providerOf(item); + const bucket = grouped.get(provider); + if (bucket) { + bucket.push(item); + continue; + } + providerOrder.push(provider); + grouped.set(provider, [item]); + } + + const selected: T[] = []; + while (selected.length < maxItems && grouped.size > 0) { + for (const provider of providerOrder) { + const bucket = grouped.get(provider); + if (!bucket || bucket.length === 0) { + continue; + } + const item = bucket.shift(); + if (item) { + selected.push(item); + } + if (bucket.length === 0) { + grouped.delete(provider); + } + if (selected.length >= maxItems) { + break; + } + } + } + return selected; +} + function resolveTestReasoning( model: Model, ): "minimal" | "low" | "medium" | "high" | "xhigh" | undefined { @@ -122,16 +169,32 @@ async function completeSimpleWithTimeout( options: Parameters>[2], timeoutMs: number, ) { + const maxTimeoutMs = Math.max(1, timeoutMs); const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutMs)); - timer.unref?.(); + const abortTimer = setTimeout(() => { + controller.abort(); + }, maxTimeoutMs); + abortTimer.unref?.(); + let hardTimer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + hardTimer = setTimeout(() => { + reject(new Error(`model call timed out after ${maxTimeoutMs}ms`)); + }, maxTimeoutMs); + hardTimer.unref?.(); + }); try { - return await completeSimple(model, context, { - ...options, - signal: controller.signal, - }); + return await Promise.race([ + completeSimple(model, context, { + ...options, + signal: controller.signal, + }), + timeout, + ]); } finally { - clearTimeout(timer); + clearTimeout(abortTimer); + if (hardTimer) { + clearTimeout(hardTimer); + } } } @@ -205,6 +268,7 @@ describeLive("live models (profile keys)", () => { const allowNotFoundSkip = useModern; const providers = parseProviderFilter(process.env.OPENCLAW_LIVE_PROVIDERS); const perModelTimeoutMs = toInt(process.env.OPENCLAW_LIVE_MODEL_TIMEOUT_MS, 30_000); + const maxModels = toInt(process.env.OPENCLAW_LIVE_MAX_MODELS, 0); const failures: Array<{ model: string; error: string }> = []; const skipped: Array<{ model: string; reason: string }> = []; @@ -246,11 +310,21 @@ describeLive("live models (profile keys)", () => { return; } + const selectedCandidates = capByProviderSpread( + candidates, + maxModels > 0 ? maxModels : candidates.length, + (entry) => entry.model.provider, + ); logProgress(`[live-models] selection=${useExplicit ? "explicit" : "modern"}`); - logProgress(`[live-models] running ${candidates.length} models`); - const total = candidates.length; + if (selectedCandidates.length < candidates.length) { + logProgress( + `[live-models] capped to ${selectedCandidates.length}/${candidates.length} via OPENCLAW_LIVE_MAX_MODELS=${maxModels}`, + ); + } + logProgress(`[live-models] running ${selectedCandidates.length} models`); + const total = selectedCandidates.length; - for (const [index, entry] of candidates.entries()) { + for (const [index, entry] of selectedCandidates.entries()) { const { model, apiKeyInfo } = entry; const id = `${model.provider}/${model.id}`; const progressLabel = `[live-models] ${index + 1}/${total} ${id}`; @@ -513,6 +587,11 @@ describeLive("live models (profile keys)", () => { logProgress(`${progressLabel}: skip (instructions required)`); break; } + if (allowNotFoundSkip && isModelTimeoutError(message)) { + skipped.push({ model: id, reason: message }); + logProgress(`${progressLabel}: skip (timeout)`); + break; + } logProgress(`${progressLabel}: failed`); failures.push({ model: id, error: message }); break; diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 0140a6569d96..f8cd415cfe02 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -55,6 +55,58 @@ function parseFilter(raw?: string): Set | null { return ids.length ? new Set(ids) : null; } +function toInt(value: string | undefined, fallback: number): number { + const trimmed = value?.trim(); + if (!trimmed) { + return fallback; + } + const parsed = Number.parseInt(trimmed, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function capByProviderSpread( + items: T[], + maxItems: number, + providerOf: (item: T) => string, +): T[] { + if (maxItems <= 0 || items.length <= maxItems) { + return items; + } + const providerOrder: string[] = []; + const grouped = new Map(); + for (const item of items) { + const provider = providerOf(item); + const bucket = grouped.get(provider); + if (bucket) { + bucket.push(item); + continue; + } + providerOrder.push(provider); + grouped.set(provider, [item]); + } + + const selected: T[] = []; + while (selected.length < maxItems && grouped.size > 0) { + for (const provider of providerOrder) { + const bucket = grouped.get(provider); + if (!bucket || bucket.length === 0) { + continue; + } + const item = bucket.shift(); + if (item) { + selected.push(item); + } + if (bucket.length === 0) { + grouped.delete(provider); + } + if (selected.length >= maxItems) { + break; + } + } + } + return selected; +} + function logProgress(message: string): void { console.log(`[live] ${message}`); } @@ -1061,6 +1113,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { const useModern = !rawModels || rawModels === "modern" || rawModels === "all"; const useExplicit = Boolean(rawModels) && !useModern; const filter = useExplicit ? parseFilter(rawModels) : null; + const maxModels = toInt(process.env.OPENCLAW_LIVE_GATEWAY_MAX_MODELS, 0); const wanted = filter ? all.filter((m) => filter.has(`${m.provider}/${m.id}`)) : all.filter((m) => isModernModelRef({ provider: m.provider, id: m.id })); @@ -1091,21 +1144,31 @@ describeLive("gateway live (dev agent, profile keys)", () => { logProgress("[all-models] no API keys found; skipping"); return; } + const selectedCandidates = capByProviderSpread( + candidates, + maxModels > 0 ? maxModels : candidates.length, + (model) => model.provider, + ); logProgress(`[all-models] selection=${useExplicit ? "explicit" : "modern"}`); - const imageCandidates = candidates.filter((m) => m.input?.includes("image")); + if (selectedCandidates.length < candidates.length) { + logProgress( + `[all-models] capped to ${selectedCandidates.length}/${candidates.length} via OPENCLAW_LIVE_GATEWAY_MAX_MODELS=${maxModels}`, + ); + } + const imageCandidates = selectedCandidates.filter((m) => m.input?.includes("image")); if (imageCandidates.length === 0) { logProgress("[all-models] no image-capable models selected; image probe will be skipped"); } await runGatewayModelSuite({ label: "all-models", cfg, - candidates, + candidates: selectedCandidates, extraToolProbes: true, extraImageProbes: true, thinkingLevel: THINKING_LEVEL, }); - const minimaxCandidates = candidates.filter((model) => model.provider === "minimax"); + const minimaxCandidates = selectedCandidates.filter((model) => model.provider === "minimax"); if (minimaxCandidates.length === 0) { logProgress("[minimax] no candidates with keys; skipping dual endpoint probes"); return; From 069c495df630b49ad8e690da7b2ab3c6c6874706 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 02:50:14 +0000 Subject: [PATCH 366/408] docs: clarify pairing commands in faq and troubleshooting --- docs/gateway/troubleshooting.md | 6 +++--- docs/help/faq.md | 4 ++-- docs/help/troubleshooting.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 69bf0c450d7b..23483076102b 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -36,7 +36,7 @@ If channels are up but nothing answers, check routing and policy before reconnec ```bash openclaw status openclaw channels status --probe -openclaw pairing list +openclaw pairing list --channel [--account ] openclaw config get channels openclaw logs --follow ``` @@ -125,7 +125,7 @@ If channel state is connected but message flow is dead, focus on policy, permiss ```bash openclaw channels status --probe -openclaw pairing list +openclaw pairing list --channel [--account ] openclaw status --deep openclaw logs --follow openclaw config get channels @@ -290,7 +290,7 @@ Common signatures: ```bash openclaw devices list -openclaw pairing list +openclaw pairing list --channel [--account ] openclaw logs --follow openclaw doctor ``` diff --git a/docs/help/faq.md b/docs/help/faq.md index 4cf1c7447ed7..a16dba1a7dc1 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -2705,8 +2705,8 @@ Treat inbound DMs as untrusted input. Defaults are designed to reduce risk: - Default behavior on DM-capable channels is **pairing**: - Unknown senders receive a pairing code; the bot does not process their message. - - Approve with: `openclaw pairing approve ` - - Pending requests are capped at **3 per channel**; check `openclaw pairing list ` if a code didn't arrive. + - Approve with: `openclaw pairing approve --channel [--account ] ` + - Pending requests are capped at **3 per channel**; check `openclaw pairing list --channel [--account ]` if a code didn't arrive. - Opening DMs publicly requires explicit opt-in (`dmPolicy: "open"` and allowlist `"*"`). Run `openclaw doctor` to surface risky DM policies. diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 83cad80ba32a..c4754da18673 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -62,7 +62,7 @@ flowchart TD openclaw status openclaw gateway status openclaw channels status --probe - openclaw pairing list + openclaw pairing list --channel [--account ] openclaw logs --follow ``` From 74e5cbfc120894934f4b07792d64bd1c2efe8a2a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 03:00:39 +0000 Subject: [PATCH 367/408] build: update appcast for 2026.2.24 beta --- appcast.xml | 319 ++++++++++++++-------------------------------------- 1 file changed, 87 insertions(+), 232 deletions(-) diff --git a/appcast.xml b/appcast.xml index 0f8acfe3a3a6..902d60972fd7 100644 --- a/appcast.xml +++ b/appcast.xml @@ -209,251 +209,106 @@ - 2026.2.22 - Mon, 23 Feb 2026 01:51:13 +0100 + 2026.2.24 + Wed, 25 Feb 2026 02:59:30 +0000 https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml - 14126 - 2026.2.22 + 14728 + 2026.2.24 15.0 - OpenClaw 2026.2.22 + OpenClaw 2026.2.24

Changes

    -
  • Provider/Mistral: add support for the Mistral provider, including memory embeddings and voice support. (#23845) Thanks @vincentkoc.
  • -
  • Update/Core: add an optional built-in auto-updater for package installs (update.auto.*), default-off, with stable rollout delay+jitter and beta hourly cadence.
  • -
  • CLI/Update: add openclaw update --dry-run to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting.
  • -
  • Config/UI: add tag-aware settings filtering and broaden config labels/help copy so fields are easier to discover and understand in the dashboard config screen.
  • -
  • Channels/Synology Chat: add a native Synology Chat channel plugin with webhook ingress, direct-message routing, outbound send/media support, per-account config, and DM policy controls. (#23012)
  • -
  • iOS/Talk: prefetch TTS segments and suppress expected speech-cancellation errors for smoother talk playback. (#22833) Thanks @ngutman.
  • -
  • Memory/FTS: add Spanish and Portuguese stop-word filtering for query expansion in FTS-only search mode, improving conversational recall for both languages. Thanks @vincentkoc.
  • -
  • Memory/FTS: add Japanese-aware query expansion tokenization and stop-word filtering (including mixed-script terms like ASCII + katakana) for FTS-only search mode. Thanks @vincentkoc.
  • -
  • Memory/FTS: add Korean stop-word filtering and particle-aware keyword extraction (including mixed Korean/English stems) for query expansion in FTS-only search mode. (#18899) Thanks @ruypang.
  • -
  • Memory/FTS: add Arabic stop-word filtering for query expansion in FTS-only search mode to reduce conversational filler in Arabic memory searches. Thanks @vincentkoc.
  • -
  • Discord/Allowlist: canonicalize resolved Discord allowlist names to IDs and split resolution flow for clearer fail-closed behavior.
  • -
  • Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path.
  • -
  • Gateway/Auth: unify call/probe/status/auth credential-source precedence on shared resolver helpers, with table-driven parity coverage across gateway entrypoints.
  • -
  • Gateway/Auth: refactor gateway credential resolution and websocket auth handshake paths to use shared typed auth contexts, including explicit auth.deviceToken support in connect frames and tests.
  • -
  • Skills: remove bundled food-order skill from this repo; manage/install it from ClawHub instead.
  • -
  • Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz.
  • +
  • Auto-reply/Abort shortcuts: expand standalone stop phrases (stop openclaw, stop action, stop run, stop agent, please stop, and related variants), accept trailing punctuation (for example STOP OPENCLAW!!!), add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms), and treat exact do not do that as a stop trigger while preserving strict standalone matching. (#25103) Thanks @steipete and @vincentkoc.
  • +
  • Android/App UX: ship a native four-step onboarding flow, move post-onboarding into a five-tab shell (Connect, Chat, Voice, Screen, Settings), add a full Connect setup/manual mode screen, and refresh Android chat/settings surfaces for the new navigation model.
  • +
  • Talk/Gateway config: add provider-agnostic Talk configuration with legacy compatibility, and expose gateway Talk ElevenLabs config metadata for setup/status surfaces.
  • +
  • Security/Audit: add security.trust_model.multi_user_heuristic to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (sandbox.mode="all", workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes).
  • +
  • Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping @buape/carbon pinned.

Breaking

    -
  • BREAKING: tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require /verbose on or /verbose full.
  • -
  • BREAKING: CLI local onboarding now sets session.dmScope to per-channel-peer by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set session.dmScope to main. (#23468) Thanks @bmendonca3.
  • -
  • BREAKING: unify channel preview-streaming config to channels..streaming with enum values off | partial | block | progress, and move Slack native stream toggle to channels.slack.nativeStreaming. Legacy keys (streamMode, Slack boolean streaming) are still read and migrated by openclaw doctor --fix, but canonical saved config/docs now use the unified names.
  • -
  • BREAKING: remove legacy Gateway device-auth signature v1. Device-auth clients must now sign v2 payloads with the per-connection connect.challenge nonce and send device.nonce; nonce-less connects are rejected.
  • +
  • BREAKING: Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example user:, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages.
  • +
  • BREAKING: Security/Sandbox: block Docker network: "container:" namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true (break-glass). Thanks @tdjackey for reporting.

Fixes

    -
  • Security/CLI: redact sensitive values in openclaw config get output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo.
  • -
  • Install/Discord Voice: make @discordjs/opus an optional dependency so openclaw install/update no longer hard-fails when native Opus builds fail, while keeping opusscript as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman.
  • -
  • Docker/Setup: precreate $OPENCLAW_CONFIG_DIR/identity during docker-setup.sh so CLI commands that need device identity (for example devices list) avoid EACCES ... /home/node/.openclaw/identity failures on restrictive bind mounts. (#23948) Thanks @ackson-beep.
  • -
  • Exec/Background: stop applying the default exec timeout to background sessions (background: true or explicit yieldMs) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303)
  • -
  • Slack/Threading: sessions: keep parent-session forking and thread-history context active beyond first turn by removing first-turn-only gates in session init, thread-history fetch, and reply prompt context injection. (#23843, #23090) Thanks @vincentkoc and @Taskle.
  • -
  • Slack/Threading: respect replyToMode when Slack auto-populates top-level thread_ts, and ignore inline replyToId directive tags when replyToMode is off so thread forcing stays disabled unless explicitly configured. (#23839, #23320, #23513) Thanks @vincentkoc and @dorukardahan.
  • -
  • Slack/Extension: forward message read threadId to readMessages and use delivery-context threadId as outbound thread_ts fallback so extension replies/reads stay in the correct Slack thread. (#22216, #22485, #23836) Thanks @vincentkoc, @lan17 and @dorukardahan.
  • -
  • Slack/Upload: resolve bare user IDs (U-prefix) to DM channel IDs via conversations.open before calling files.uploadV2, which rejects non-channel IDs. chat.postMessage tolerates user IDs directly, but files.uploadV2completeUploadExternal validates channel_id against ^[CGDZ][A-Z0-9]{8,}$, causing invalid_arguments when agents reply with media to DM conversations.
  • -
  • Webchat/Chat: apply assistant final payload messages directly to chat state so sent turns render without waiting for a full history refresh cycle. (#14928) Thanks @BradGroux.
  • -
  • Webchat/Chat: for out-of-band final events (for example tool-call side runs), append provided final assistant payloads directly instead of forcing a transient history reset. (#11139) Thanks @AkshayNavle.
  • -
  • Webchat/Performance: reload chat.history after final events only when the final payload lacks a renderable assistant message, avoiding expensive full-history refreshes on normal turns. (#20588) Thanks @amzzzzzzz.
  • -
  • Webchat/Sessions: preserve external session routing metadata when internal chat.send turns run under webchat, so explicit channel-keyed sessions (for example Telegram) no longer get rewritten to webchat and misroute follow-up delivery. (#23258) Thanks @binary64.
  • -
  • Webchat/Sessions: preserve existing session label across /new and /reset rollovers so reset sessions remain discoverable in session history lists. (#23755) Thanks @ThunderStormer.
  • -
  • Gateway/Chat UI: strip inline reply/audio directive tags from non-streaming final webchat broadcasts (including chat.inject) while preserving empty-string message content when tags are the entire reply. (#23298) Thanks @SidQin-cyber.
  • -
  • Chat/UI: strip inline reply/audio directive tags ([[reply_to_current]], [[reply_to:]], [[audio_as_voice]]) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces.
  • -
  • Telegram/Media: send a user-facing Telegram reply when media download fails (non-size errors) instead of silently dropping the message.
  • -
  • Telegram/Webhook: keep webhook monitors alive until gateway abort signals fire, preventing false channel exits and immediate webhook auto-restart loops.
  • -
  • Telegram/Polling: retry recoverable setup-time network failures in monitor startup and await runner teardown before retry to avoid overlapping polling sessions.
  • -
  • Telegram/Polling: clear Telegram webhooks (deleteWebhook) before starting long-poll getUpdates, including retry handling for transient cleanup failures.
  • -
  • Telegram/Webhook: add channels.telegram.webhookPort config support and pass it through plugin startup wiring to the monitor listener.
  • -
  • Browser/Extension Relay: refactor the MV3 worker to preserve debugger attachments across relay drops, auto-reconnect with bounded backoff+jitter, persist and rehydrate attached tab state via chrome.storage.session, recover from target_closed navigation detaches, guard stale socket handlers, enforce per-tab operation locks and per-request timeouts, and add lifecycle keepalive/badge refresh hooks (alarms, webNavigation). (#15099, #6175, #8468, #9807)
  • -
  • Browser/Relay: treat extension websocket as connected only when OPEN, allow reconnect when a stale CLOSING/CLOSED extension socket lingers, and guard stale socket message/close handlers so late events cannot clear active relay state; includes regression coverage for live-duplicate 409 rejection and immediate reconnect-after-close races. (#15099, #18698, #20688)
  • -
  • Browser/Remote CDP: extend stale-target recovery so ensureTabAvailable() now reuses the sole available tab for remote CDP profiles (same behavior as extension profiles) while preserving strict tab not found errors when multiple tabs exist; includes remote-profile regression tests. (#15989)
  • -
  • Gateway/Pairing: treat operator.admin as satisfying other operator.* scope checks during device-auth verification so local CLI/TUI sessions stop entering pairing-required loops for pairing/approval-scoped commands. (#22062, #22193, #21191) Thanks @Botaccess, @jhartshorn, and @ctbritt.
  • -
  • Gateway/Pairing: auto-approve loopback scope-upgrade pairing requests (including device-token reconnects) so local clients do not disconnect on pairing-required scope elevation. (#23708) Thanks @widingmarcus-cyber.
  • -
  • Gateway/Scopes: include operator.read and operator.write in default operator connect scope bundles across CLI, Control UI, and macOS clients so write-scoped announce/sub-agent follow-up calls no longer hit pairing required disconnects on loopback gateways. (#22582) thanks @YuzuruS.
  • -
  • Gateway/Pairing: treat operator.admin pairing tokens as satisfying operator.write requests so legacy devices stop looping through scope-upgrade prompts introduced in 2026.2.19. (#23125, #23006) Thanks @vignesh07.
  • -
  • Gateway/Restart: fix restart-loop edge cases by keeping openclaw.mjs -> dist/entry.js bootstrap detection explicit, reacquiring the gateway lock for in-process restart fallback paths, and tightening restart-loop regression coverage. (#23416) Thanks @jeffwnli.
  • -
  • Gateway/Lock: use optional gateway-port reachability as a primary stale-lock liveness signal (and wire gateway run-loop lock acquisition to the resolved port), reducing false "already running" lockouts after unclean exits. (#23760) Thanks @Operative-001.
  • -
  • Delivery/Queue: quarantine queue entries immediately on known permanent delivery errors (for example invalid recipients or missing conversation references) by moving them to failed/ instead of retrying on every restart. (#23794) Thanks @aldoeliacim.
  • -
  • Cron/Status: split execution outcome (lastRunStatus) from delivery outcome (lastDeliveryStatus) in persisted cron state, finished events, and run history so failed/unknown announcement delivery is visible without conflating it with run errors.
  • -
  • Cron/Delivery: route text-only announce jobs with explicit thread/topic targets through direct outbound delivery so forum/thread destinations do not get dropped by intermediary announce turns. (#23841) Thanks @AndrewArto.
  • -
  • Cron: honor cron.maxConcurrentRuns in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman.
  • -
  • Cron/Run: enforce the same per-job timeout guard for manual cron.run executions as timer-driven runs, including abort propagation for isolated agent jobs, so forced runs cannot wedge indefinitely. (#23704) Thanks @tkuehnl.
  • -
  • Cron/Run: persist the manual-run runningAtMs marker before releasing the cron lock so overlapping timer ticks cannot start the same job concurrently.
  • -
  • Cron/Startup: enforce per-job timeout guards for startup catch-up replay runs so missed isolated jobs cannot hang indefinitely during gateway boot recovery.
  • -
  • Cron/Main session: honor abort/timeout signals while retrying wakeMode=now heartbeat contention loops so main-target cron runs stop promptly instead of waiting through the full busy-retry window.
  • -
  • Cron/Schedule: for every jobs, prefer lastRunAtMs + everyMs when still in the future after restarts, then fall back to anchor scheduling for catch-up windows, so NEXT timing matches the last successful cadence. (#22895) Thanks @SidQin-cyber.
  • -
  • Cron/Service: execute manual cron.run jobs outside the cron lock (while still persisting started/finished state atomically) so cron.list and cron.status remain responsive during long forced runs. (#23628) Thanks @dsgraves.
  • -
  • Cron/Timer: keep a watchdog recheck timer armed while onTimer is actively executing so the scheduler continues polling even if a due-run tick stalls for an extended period. (#23628) Thanks @dsgraves.
  • -
  • Cron/Run log: clean up settled per-path run-log write queue entries so long-running cron uptime does not retain stale promise bookkeeping in memory.
  • -
  • Cron/Isolation: force fresh session IDs for isolated cron runs so sessionTarget="isolated" executions never reuse prior run context. (#23470) Thanks @echoVic.
  • -
  • Plugins/Install: strip workspace:* devDependency entries from copied plugin manifests before npm install --omit=dev, preventing EUNSUPPORTEDPROTOCOL install failures for npm-published channel plugins (including Feishu and MS Teams).
  • -
  • Feishu/Plugins: restore bundled Feishu SDK availability for global installs and strip openclaw: workspace:* from plugin devDependencies during plugin-version sync so npm-installed Feishu plugins do not fail dependency install. (#23611, #23645, #23603)
  • -
  • Config/Channels: auto-enable built-in channels by writing channels..enabled=true (not plugins.entries.), and stop adding built-ins to plugins.allow, preventing plugins.entries.telegram: plugin not found validation failures.
  • -
  • Config/Channels: when plugins.allow is active, auto-enable/enable flows now also allowlist configured built-in channels so channels..enabled=true cannot remain blocked by restrictive plugin allowlists.
  • -
  • Plugins/Discovery: ignore scanned extension backup/disabled directory patterns (for example .backup-*, .bak, .disabled*) and move updater backup directories under .openclaw-install-backups, preventing duplicate plugin-id collisions from archived copies.
  • -
  • Plugins/CLI: make openclaw plugins enable and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl.
  • -
  • Security/Voice Call: harden media stream WebSocket handling against pre-auth idle-connection DoS by adding strict pre-start timeouts, pending/per-IP connection limits, and total connection caps for streaming endpoints. This ships in the next npm release. Thanks @jiseoung for reporting.
  • -
  • Security/Sessions: redact sensitive token patterns from sessions_history tool output and surface contentRedacted metadata when masking occurs. (#16928) Thanks @aether-ai-agent.
  • -
  • Security/Exec: stop trusting PATH-derived directories for safe-bin allowlist checks, add explicit tools.exec.safeBinTrustedDirs, and pin safe-bin shell execution to resolved absolute executable paths to prevent binary-shadowing approval bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/Elevated: match tools.elevated.allowFrom against sender identities only (not recipient ctx.To), closing a recipient-token bypass for /elevated authorization. This ships in the next npm release. Thanks @jiseoung for reporting.
  • -
  • Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. This ships in the next npm release. Thanks @jiseoung for reporting.
  • -
  • Security/Group policy: harden channels.*.groups.*.toolsBySender matching by requiring explicit sender-key types (id:, e164:, username:, name:), preventing cross-identifier collisions across mutable/display-name fields while keeping legacy untyped keys on a deprecated ID-only path. This ships in the next npm release. Thanks @jiseoung for reporting.
  • -
  • Channels/Group policy: fail closed when groupPolicy: "allowlist" is set without explicit groups, honor account-level groupPolicy overrides, and enforce groupPolicy: "disabled" as a hard group block. (#22215) Thanks @etereo.
  • -
  • Telegram/Discord extensions: propagate trusted mediaLocalRoots through extension outbound sendMedia options so extension direct-send media paths honor agent-scoped local-media allowlists. (#20029, #21903, #23227)
  • -
  • Agents/Exec: honor explicit agent context when resolving tools.exec defaults for runs with opaque/non-agent session keys, so per-agent host/security/ask policies are applied consistently. (#11832)
  • -
  • Doctor/Security: add an explicit warning that approvals.exec.enabled=false disables forwarding only, while enforcement remains driven by host-local exec-approvals.json policy. (#15047)
  • -
  • Sandbox/Docker: default sandbox container user to the workspace owner uid:gid when agents.*.sandbox.docker.user is unset, fixing non-root gateway file-tool permissions under capability-dropped containers. (#20979)
  • -
  • Plugins/Media sandbox: propagate trusted mediaLocalRoots through plugin action dispatch (including Discord/Telegram action adapters) so plugin send paths enforce the same agent-scoped local-media sandbox roots as core outbound sends. (#20258, #22718)
  • -
  • Agents/Workspace guard: map sandbox container-workdir file-tool paths (for example /workspace/... and file:///workspace/...) to host workspace roots before workspace-only validation, preventing false Path escapes sandbox root rejections for sandbox file tools. (#9560)
  • -
  • Gateway/Exec approvals: expire approval requests immediately when no approval-capable gateway clients are connected and no forwarding targets are available, avoiding delayed approvals after restarts/offline approver windows. (#22144)
  • -
  • Security/Exec approvals: when approving wrapper commands with allow-always in allowlist mode, persist inner executable paths for known dispatch wrappers (env, nice, nohup, stdbuf, timeout) and fail closed (no persisted entry) when wrapper unwrapping is not safe, preventing wrapper-path approval bypasses. Thanks @tdjackey for reporting.
  • -
  • Node/macOS exec host: default headless macOS node system.run to local execution and only route through the companion app when OPENCLAW_NODE_EXEC_HOST=app is explicitly set, avoiding companion-app filesystem namespace mismatches during exec. (#23547)
  • -
  • Sandbox/Media: map container workspace paths (/workspace/... and file:///workspace/...) back to the host sandbox root for outbound media validation, preventing false deny errors for sandbox-generated local media. (#23083) Thanks @echo931.
  • -
  • Sandbox/Docker: apply custom bind mounts after workspace mounts and prioritize bind-source resolution on overlapping paths, so explicit workspace binds are no longer ignored. (#22669) Thanks @tasaankaeris.
  • -
  • Exec approvals/Forwarding: restore Discord text forwarding when component approvals are not configured, and carry request snapshots through resolve events so resolved notices still forward after cache misses/restarts. (#22988) Thanks @bubmiller.
  • -
  • Control UI/WebSocket: stop and clear the browser gateway client on UI teardown so remounts cannot leave orphan websocket clients that create duplicate active connections. (#23422) Thanks @floatinggball-design.
  • -
  • Control UI/WebSocket: send a stable per-tab instanceId in websocket connect frames so reconnect cycles keep a consistent client identity for diagnostics and presence tracking. (#23616) Thanks @zq58855371-ui.
  • -
  • Config/Memory: allow "mistral" in agents.defaults.memorySearch.provider and agents.defaults.memorySearch.fallback schema validation. (#14934) Thanks @ThomsenDrake.
  • -
  • Feishu/Commands: in group chats, command authorization now falls back to top-level channels.feishu.allowFrom when per-group allowFrom is not set, so /command no longer gets blocked by an unintended empty allowlist. (#23756)
  • -
  • Dev tooling: prevent CLAUDE.md symlink target regressions by excluding CLAUDE symlink sentinels from oxfmt and marking them -text in .gitattributes, so formatter/EOL normalization cannot reintroduce trailing-newline targets. Thanks @vincentkoc.
  • -
  • Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.
  • -
  • Feishu/Media: for inbound video messages that include both file_key (video) and image_key (thumbnail), prefer file_key when downloading media so video attachments are saved instead of silently failing on thumbnail keys. (#23633)
  • -
  • Hooks/Loader: avoid redundant hook-module recompilation on gateway restart by skipping cache-busting for bundled hooks and using stable file metadata keys (mtime+size) for mutable workspace/managed/plugin hook imports. (#16953) Thanks @mudrii.
  • -
  • Hooks/Cron: suppress duplicate main-session events for delivered hook turns and mark SILENT_REPLY_TOKEN (NO_REPLY) early exits as delivered to prevent hook context pollution. (#20678) Thanks @JonathanWorks.
  • -
  • Providers/OpenRouter: inject cache_control on system prompts for OpenRouter Anthropic models to improve prompt-cache reuse. (#17473) Thanks @rrenamed.
  • -
  • Installer/Smoke tests: remove legacy OPENCLAW_USE_GUM overrides from docker install-smoke runs so tests exercise installer auto TTY detection behavior directly.
  • -
  • Providers/OpenRouter: allow pass-through OpenRouter and Opencode model IDs in live model filtering so custom routed model IDs are treated as modern refs. (#14312) Thanks @Joly0.
  • -
  • Providers/OpenRouter: default reasoning to enabled when the selected model advertises reasoning: true and no session/directive override is set. (#22513) Thanks @zwffff.
  • -
  • Providers/OpenRouter: map /think levels to reasoning.effort in embedded runs while preserving explicit reasoning.max_tokens payloads. (#17236) Thanks @robbyczgw-cla.
  • -
  • Providers/OpenRouter: preserve stored session provider when model IDs are vendor-prefixed (for example, anthropic/...) so follow-up turns do not incorrectly route to direct provider APIs. (#22753) Thanks @dndodson.
  • -
  • Providers/OpenRouter: preserve the required openrouter/ prefix for OpenRouter-native model IDs during model-ref normalization. (#12942) Thanks @omair445.
  • -
  • Providers/OpenRouter: pass through provider routing parameters from model params.provider to OpenRouter request payloads for provider selection controls. (#17148) Thanks @carrotRakko.
  • -
  • Providers/OpenRouter: preserve model allowlist entries containing OpenRouter preset paths (for example openrouter/@preset/...) by treating /model ...@profile auth-profile parsing as a suffix-only override. (#14120) Thanks @NotMainstream.
  • -
  • Cron/Auth: propagate auth-profile resolution to isolated cron sessions so provider API keys are resolved the same way as main sessions, fixing 401 errors when using providers configured via auth-profiles. (#20689) Thanks @lailoo.
  • -
  • Cron/Follow-up: pass resolved agentDir through isolated cron and queued follow-up embedded runs so auth/profile lookups stay scoped to the correct agent directory. (#22845) Thanks @seilk.
  • -
  • Agents/Media: route tool-result MEDIA: extraction through shared parser validation so malformed prose like MEDIA:-prefixed ... is no longer treated as a local file path (prevents Telegram ENOENT tool-error overrides). (#18780) Thanks @HOYALIM.
  • -
  • Logging: cap single log-file size with logging.maxFileBytes (default 500 MB) and suppress additional writes after cap hit to prevent disk exhaustion from repeated error storms.
  • -
  • Memory/Remote HTTP: centralize remote memory HTTP calls behind a shared guarded helper (withRemoteHttpResponse) so embeddings and batch flows use one request/release path.
  • -
  • Memory/Embeddings: apply configured remote-base host pinning (allowedHostnames) across OpenAI/Voyage/Gemini embedding requests to keep private/self-hosted endpoints working without cross-host drift. (#18198) Thanks @ianpcook.
  • -
  • Memory/Batch: route OpenAI/Voyage/Gemini batch upload/create/status/download requests through the same guarded HTTP path for consistent SSRF policy enforcement.
  • -
  • Memory/Index: detect memory source-set changes (for example enabling sessions after an existing memory-only index) and trigger a full reindex so existing session transcripts are indexed without requiring --force. (#17576) Thanks @TarsAI-Agent.
  • -
  • Memory/Embeddings: enforce a per-input 8k safety cap before embedding batching and apply a conservative 2k fallback limit for local providers without declared input limits, preventing oversized session/memory chunks from triggering provider context-size failures during sync/indexing. (#6016) Thanks @batumilove.
  • -
  • Memory/QMD: on Windows, resolve bare qmd/mcporter command names to npm shim executables (.cmd) before spawning, so qmd boot updates and mcporter-backed searches no longer fail with spawn ... ENOENT on default npm installs. (#23899) Thanks @arcbuilder-ai.
  • -
  • Memory/QMD: parse plain-text qmd collection list --json output when older qmd builds ignore JSON mode, and retry memory searches once after re-ensuring managed collections when qmd returns Collection not found .... (#23613) Thanks @leozhucn.
  • -
  • Signal/RPC: guard malformed Signal RPC JSON responses with a clear status-scoped error and add regression coverage for invalid JSON responses. (#22995) Thanks @adhitShet.
  • -
  • Gateway/Subagents: guard gateway and subagent session-key/message trim paths against undefined inputs to prevent early Cannot read properties of undefined (reading 'trim') crashes during subagent spawn and wait flows.
  • -
  • Agents/Workspace: guard resolveUserPath against undefined/null input to prevent Cannot read properties of undefined (reading 'trim') crashes when workspace paths are missing in embedded runner flows.
  • -
  • Auth/Profiles: keep active cooldownUntil/disabledUntil windows immutable across retries so mid-window failures cannot extend recovery indefinitely; only recompute a backoff window after the previous deadline has expired. This resolves cron/inbound retry loops that could trap gateways until manual usageStats cleanup. (#23516, #23536) Thanks @arosstale.
  • -
  • Channels/Security: fail closed on missing provider group policy config by defaulting runtime group policy to allowlist (instead of inheriting channels.defaults.groupPolicy) when channels. is absent across message channels, and align runtime + security warnings/docs to the same fallback behavior (Slack, Discord, iMessage, Telegram, WhatsApp, Signal, LINE, Matrix, Mattermost, Google Chat, IRC, Nextcloud Talk, Feishu, and Zalo user flows; plus Discord message/native-command paths). (#23367) Thanks @bmendonca3.
  • -
  • Gateway/Onboarding: harden remote gateway onboarding defaults and guidance by defaulting discovered direct URLs to wss://, rejecting insecure non-loopback ws:// targets in onboarding validation, and expanding remote-security remediation messaging across gateway client/call/doctor flows. (#23476) Thanks @bmendonca3.
  • -
  • CLI/Sessions: pass the configured sessions directory when resolving transcript paths in agentCommand, so custom session.store locations resume sessions reliably. Thanks @davidrudduck.
  • -
  • Signal/Monitor: treat user-initiated abort shutdowns as clean exits when auto-started signal-cli is terminated, while still surfacing unexpected daemon exits as startup/runtime failures. (#23379) Thanks @frankekn.
  • -
  • Channels/Dedupe: centralize plugin dedupe primitives in plugin SDK (memory + persistent), move Feishu inbound dedupe to a namespace-scoped persistent store, and reuse shared dedupe cache logic for Zalo webhook replay + Tlon processed-message tracking to reduce duplicate handling during reconnect/replay paths. (#23377) Thanks @SidQin-cyber.
  • -
  • Channels/Delivery: remove hardcoded WhatsApp delivery fallbacks; require explicit/session channel context or auto-pick the sole configured channel when unambiguous. (#23357) Thanks @lbo728.
  • -
  • ACP/Gateway: wait for gateway hello before opening ACP requests, and fail fast on pre-hello connect failures to avoid startup hangs and early gateway not connected request races. (#23390) Thanks @janckerchen.
  • -
  • Gateway/Auth: preserve OPENCLAW_GATEWAY_PASSWORD env override precedence for remote gateway call credentials after shared resolver refactors, preventing stale configured remote passwords from overriding runtime secret rotation.
  • -
  • Gateway/Auth: preserve shared-token gateway token mismatch auth errors when auth.token fallback device-token checks fail, and reserve device token mismatch guidance for explicit auth.deviceToken failures.
  • -
  • Gateway/Tools: when agent tools pass an allowlisted gatewayUrl override, resolve local override tokens from env/config fallback but keep remote overrides strict to gateway.remote.token, preventing local token leakage to remote targets.
  • -
  • Gateway/Client: keep cached device-auth tokens on device token mismatch closes when the client used explicit shared token/password credentials, avoiding accidental pairing-token churn during explicit-auth failures.
  • -
  • Node host/Exec: keep strict Windows allowlist behavior for cmd.exe /c shell-wrapper runs, and return explicit approval guidance when blocked (SYSTEM_RUN_DENIED: allowlist miss).
  • -
  • Control UI: show pairing-required guidance (commands + mobile tokenized URL reminder) when the dashboard disconnects with 1008 pairing required.
  • -
  • Security/Audit: add openclaw security audit detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (security.exposure.open_groups_with_runtime_or_fs).
  • -
  • Security/Audit: make gateway.real_ip_fallback_enabled severity conditional for loopback trusted-proxy setups (warn for loopback-only trustedProxies, critical when non-loopback proxies are trusted). (#23428) Thanks @bmendonca3.
  • -
  • Security/Exec env: block request-scoped HOME and ZDOTDIR overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/Exec env: block SHELLOPTS/PS4 in host exec env sanitizers and restrict shell-wrapper (bash|sh|zsh ... -c/-lc) request env overrides to a small explicit allowlist (TERM, LANG, LC_*, COLORTERM, NO_COLOR, FORCE_COLOR) on both node host and macOS companion paths, preventing xtrace prompt command-substitution allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • WhatsApp/Security: enforce allowFrom for direct-message outbound targets in all send modes (including mode: "explicit"), preventing sends to non-allowlisted numbers. (#20108) Thanks @zahlmann.
  • -
  • Security/Exec approvals: fail closed on shell line continuations (\\\n/\\\r\n) and treat shell-wrapper execution as approval-required in allowlist mode, preventing $\\ newline command-substitution bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/Gateway: emit a startup security warning when insecure/dangerous config flags are enabled (including gateway.controlUi.dangerouslyDisableDeviceAuth=true) and point operators to openclaw security audit.
  • -
  • Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
  • -
  • Security/Exec approvals: treat env and shell-dispatch wrappers as transparent during allowlist analysis on node-host and macOS companion paths so policy checks match the effective executable/inline shell payload instead of the wrapper binary, blocking wrapper-smuggled allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/Exec approvals: require explicit safe-bin profiles for tools.exec.safeBins entries in allowlist mode (remove generic safe-bin profile fallback), and add tools.exec.safeBinProfiles for safe custom binaries so unprofiled interpreter-style entries cannot be treated as stdin-safe. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/Channels: harden Slack external menu token handling by switching to CSPRNG tokens, validating token shape, requiring user identity for external option lookups, and avoiding fabricated timestamp trigger_id fallbacks; also switch Tlon Urbit channel IDs to CSPRNG UUIDs, centralize secure ID/token generation via shared infra helpers, and add a guardrail test to block new runtime Date.now()+Math.random() token/id patterns.
  • -
  • Security/Hooks transforms: enforce symlink-safe containment for webhook transform module paths (including hooks.transformsDir and hooks.mappings[].transform.module) by resolving existing-path ancestors via realpath before import, while preserving in-root symlink support; add regression coverage for both escape and allow cases. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
  • -
  • Telegram/WSL2: disable autoSelectFamily by default on WSL2 and memoize WSL2 detection in Telegram network decision logic to avoid repeated sync /proc/version probes on fetch/send paths. (#21916) Thanks @MizukiMachine.
  • -
  • Telegram/Network: default Node 22+ DNS result ordering to ipv4first for Telegram fetch paths and add OPENCLAW_TELEGRAM_DNS_RESULT_ORDER/channels.telegram.network.dnsResultOrder overrides to reduce IPv6-path fetch failures. (#5405) Thanks @Glucksberg.
  • -
  • Telegram/Forward bursts: coalesce forwarded text+media updates through a dedicated forward lane debounce window that works with default inbound debounce config, while keeping forwarded control commands immediate. (#19476) thanks @napetrov.
  • -
  • Telegram/Streaming: preserve archived draft preview mapping after flush and clean superseded reasoning preview bubbles so multi-message preview finals no longer cross-edit or orphan stale messages under send/rotation races. (#23202) Thanks @obviyus.
  • -
  • Telegram/Replies: scope messaging-tool text/media dedupe to same-target sends only, so cross-target tool sends can no longer silently suppress Telegram final replies.
  • -
  • Telegram/Replies: normalize file:// and local-path media variants during messaging dedupe so equivalent media paths do not produce duplicate Telegram replies.
  • -
  • Telegram/Replies: extract forwarded-origin context from unified reply targets (reply_to_message and external_reply) so forward+comment metadata is preserved across partial reply shapes. (#9720) thanks @mcaxtr.
  • -
  • Telegram/Polling: persist a safe update-offset watermark bounded by pending updates so crash/restart cannot skip queued lower update_id updates after out-of-order completion. (#23284) thanks @frankekn.
  • -
  • Telegram/Polling: force-restart stuck runner instances when recoverable unhandled network rejections escape the polling task path, so polling resumes instead of silently stalling. (#19721) Thanks @jg-noncelogic.
  • -
  • Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound app.options calls. (#23209) Thanks @0xgaia.
  • -
  • Slack/Telegram slash sessions: await session metadata persistence before dispatch so first-turn native slash runs do not race session-origin metadata updates. (#23065) thanks @hydro13.
  • -
  • Slack/Queue routing: preserve string thread_ts values through collect-mode queue drain and DM deliveryContext updates so threaded follow-ups do not leak to the main channel when Slack thread IDs are strings. (#11934) Thanks @sandieman2 and @vincentkoc.
  • -
  • Telegram/Native commands: set ctx.Provider="telegram" for native slash-command context so elevated gate checks resolve provider correctly (fixes provider (ctx.Provider) failures in /elevated flows). (#23748) Thanks @serhii12.
  • -
  • Agents/Ollama: preserve unsafe integer tool-call arguments as exact strings during NDJSON parsing, preventing large numeric IDs from being rounded before tool execution. (#23170) Thanks @BestJoester.
  • -
  • Cron/Gateway: keep cron.list and cron.status responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr.
  • -
  • Gateway/Config reload: compare array-valued config paths structurally during diffing so unchanged memory.qmd.paths and memory.qmd.scope.rules no longer trigger false restart-required reloads. (#23185) Thanks @rex05ai.
  • -
  • Gateway/Config reload: retry short-lived missing config snapshots during reload before skipping, preventing atomic-write unlink windows from triggering restart loops. (#23343) Thanks @lbo728.
  • -
  • Cron/Scheduling: validate runtime cron expressions before schedule/stagger evaluation so malformed persisted jobs report a clear invalid cron schedule: expr is required error instead of crashing with undefined.trim failures and auto-disable churn. (#23223) Thanks @asimons81.
  • -
  • Memory/QMD: migrate legacy unscoped collection bindings (for example memory-root) to per-agent scoped names (for example memory-root-main) during startup when safe, so QMD-backed memory_search no longer fails with Collection not found after upgrades. (#23228, #20727) Thanks @JLDynamics and @AaronFaby.
  • -
  • Memory/QMD: normalize Han-script BM25 search queries before invoking qmd search so mixed CJK+Latin prompts no longer return empty results due to tokenizer mismatch. (#23426) Thanks @LunaLee0130.
  • -
  • TUI/Input: enable multiline-paste burst coalescing on macOS Terminal.app and iTerm so pasted blocks no longer submit line-by-line as separate messages. (#18809) Thanks @fwends.
  • -
  • TUI/RTL: isolate right-to-left script lines (Arabic/Hebrew ranges) with Unicode bidi isolation marks in TUI text sanitization so RTL assistant output no longer renders in reversed visual order in terminal chat panes. (#21936) Thanks @Asm3r96.
  • -
  • TUI/Status: request immediate renders after setting sending/waiting activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness.
  • -
  • TUI/Input: arm Ctrl+C exit timing when clearing non-empty composer text and add a SIGINT fallback path so double Ctrl+C exits remain responsive during active runs instead of requiring an extra press or appearing stuck. (#23407) Thanks @tinybluedev.
  • -
  • Agents/Fallbacks: treat JSON payloads with type: "api_error" + "Internal server error" as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane.
  • -
  • Agents/Google: sanitize non-base64 thought_signature/thoughtSignature values from assistant replay transcripts for native Google Gemini requests while preserving valid signatures and tool-call order. (#23457) Thanks @echoVic.
  • -
  • Agents/Transcripts: validate assistant tool-call names (syntax/length + registered tool allowlist) before transcript persistence and during replay sanitization so malformed failover tool names no longer poison sessions with repeated provider HTTP 400 errors. (#23324) Thanks @johnsantry.
  • -
  • Agents/Mistral: sanitize tool-call IDs in the embedded agent loop and generate strict provider-safe pending tool-call IDs, preventing Mistral strict9 HTTP 400 failures on tool continuations. (#23698) Thanks @echoVic.
  • -
  • Agents/Compaction: strip stale assistant usage snapshots from pre-compaction turns when replaying history after a compaction summary so context-token estimation no longer reuses pre-compaction totals and immediately re-triggers destructive follow-up compactions. (#19127) Thanks @tedwatson.
  • -
  • Agents/Replies: emit a default completion acknowledgement (✅ Done.) only for direct/private tool-only completions with no final assistant text, while suppressing synthetic acknowledgements for channel/group sessions and runs that already delivered output via messaging tools. (#22834) Thanks @Oldshue.
  • -
  • Agents/Subagents: honor tools.subagents.tools.alsoAllow and explicit subagent allow entries when resolving built-in subagent deny defaults, so explicitly granted tools (for example sessions_send) are no longer blocked unless re-denied in tools.subagents.tools.deny. (#23359) Thanks @goren-beehero.
  • -
  • Agents/Subagents: make announce call timeouts configurable via agents.defaults.subagents.announceTimeoutMs and restore a 60s default to prevent false timeout failures on slower announce paths. (#22719) Thanks @Valadon.
  • -
  • Agents/Diagnostics: include resolved lifecycle error text in embedded run agent end warnings so UI/TUI “Connection error” runs expose actionable provider failure reasons in gateway logs. (#23054) Thanks @Raize.
  • -
  • Agents/Auth profiles: skip auth-profile cooldown writes for timeout failures in embedded runner rotation so model/network timeouts do not poison same-provider fallback model selection while still allowing in-turn account rotation. (#22622) Thanks @vageeshkumar.
  • -
  • Plugins/Hooks: run legacy before_agent_start once per agent turn and reuse that result across model-resolve and prompt-build compatibility paths, preventing duplicate hook side effects (for example duplicate external API calls). (#23289) Thanks @ksato8710.
  • -
  • Models/Config: default missing Anthropic provider/model api fields to anthropic-messages during config validation so custom relay model entries are preserved instead of being dropped by runtime model registry validation. (#23332) Thanks @bigbigmonkey123.
  • -
  • Gateway/Pairing: preserve existing approved token scopes when processing repair pairings that omit scopes, preventing empty-scope token regressions on reconnecting clients. (#21906) Thanks @paki81.
  • -
  • Memory/QMD: add optional memory.qmd.mcporter search routing so QMD query/search/vsearch can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07.
  • -
  • Infra/Network: classify undici TypeError: fetch failed as transient in unhandled-rejection detection even when nested causes are unclassified, preventing avoidable gateway crash loops on flaky networks. (#14345) Thanks @Unayung.
  • -
  • Telegram/Retry: classify undici TypeError: fetch failed as recoverable in both polling and send retry paths so transient fetch failures no longer fail fast. (#16699) thanks @Glucksberg.
  • -
  • Docs/Telegram: correct Node 22+ network defaults (autoSelectFamily, dnsResultOrder) and clarify Telegram setup does not use positional openclaw channels login telegram. (#23609) Thanks @ryanbastic.
  • -
  • BlueBubbles/DM history: restore DM backfill context with account-scoped rolling history, bounded backfill retries, and safer history payload limits. (#20302) Thanks @Ryan-Haines.
  • -
  • BlueBubbles/Private API cache: treat unknown (null) private-API cache status as disabled for send/attachment/reply flows to avoid stale-cache 500s, and log a warning when reply/effect features are requested while capability is unknown. (#23459) Thanks @echoVic.
  • -
  • BlueBubbles/Webhooks: accept inbound/reaction webhook payloads when BlueBubbles omits handle but provides DM chatGuid, and harden payload extraction for array/string-wrapped message bodies so valid webhook events no longer get rejected as unparseable. (#23275) Thanks @toph31.
  • -
  • Security/Audit: add openclaw security audit finding gateway.nodes.allow_commands_dangerous for risky gateway.nodes.allowCommands overrides, with severity upgraded to critical on remote gateway exposure.
  • -
  • Gateway/Control plane: reduce cross-client write limiter contention by adding connId fallback keying when device ID and client IP are both unavailable.
  • -
  • Security/Config: block prototype-key traversal during config merge patch and legacy migration merge helpers (__proto__, constructor, prototype) to prevent prototype pollution during config mutation flows. (#22968) Thanks @Clawborn.
  • -
  • Security/Shell env: validate login-shell executable paths for shell-env fallback (/etc/shells + trusted prefixes), block SHELL/HOME/ZDOTDIR in config env ingestion before fallback execution, and sanitize fallback shell exec env to pin HOME to the real user home while dropping ZDOTDIR and other dangerous startup vars. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Network/SSRF: enable autoSelectFamily on pinned undici dispatchers (with attempt timeout) so IPv6-unreachable environments can quickly fall back to IPv4 for guarded fetch paths. (#19950) Thanks @ENAwareness.
  • -
  • Security/Config: make parsed chat allowlist checks fail closed when allowFrom is empty, restoring expected DM/pairing gating.
  • -
  • Security/Exec: in non-default setups that manually add sort to tools.exec.safeBins, block sort --compress-program so allowlist-mode safe-bin checks cannot bypass approval. Thanks @tdjackey for reporting.
  • -
  • Security/Exec approvals: when users choose allow-always for shell-wrapper commands (for example /bin/zsh -lc ...), persist allowlist patterns for the inner executable(s) instead of the wrapper shell binary, preventing accidental broad shell allowlisting in moderate mode. (#23276) Thanks @xrom2863.
  • -
  • Security/Exec: fail closed when tools.exec.host=sandbox is configured/requested but sandbox runtime is unavailable. (#23398) Thanks @bmendonca3.
  • -
  • Security/macOS app beta: enforce path-only system.run allowlist matching (drop basename matches like echo), migrate legacy basename entries to last resolved paths when available, and harden shell-chain handling to fail closed on unsafe parse/control syntax (including quoted command substitution/backticks). This is an optional allowlist-mode feature; default installs remain deny-by-default. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/Agents: auto-generate and persist a dedicated commands.ownerDisplaySecret when commands.ownerDisplay=hash, remove gateway token fallback from owner-ID prompt hashing across CLI and embedded agent runners, and centralize owner-display secret resolution in one shared helper. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
  • -
  • Security/SSRF: expand IPv4 fetch guard blocking to include RFC special-use/non-global ranges (including benchmarking, TEST-NET, multicast, and reserved/broadcast blocks), centralize range checks into a single CIDR policy table, and reuse one shared host/IP classifier across literal + DNS checks to reduce classifier drift. This ships in the next npm release. Thanks @princeeismond-dot for reporting.
  • -
  • Security/SSRF: block RFC2544 benchmarking range (198.18.0.0/15) across direct and embedded-IP paths, and normalize IPv6 dotted-quad transition literals (for example ::127.0.0.1, 64:ff9b::8.8.8.8) in shared IP parsing/classification.
  • -
  • Security/Archive: block zip symlink escapes during archive extraction.
  • -
  • Security/Media sandbox: keep tmp media allowance for absolute tmp paths only and enforce symlink-escape checks before sandbox-validated reads, preventing tmp symlink exfiltration and relative ../ sandbox escapes when sandboxes live under tmp. (#17892) Thanks @dashed.
  • -
  • Browser/Upload: accept canonical in-root upload paths when the configured uploads directory is a symlink alias (for example /tmp -> /private/tmp on macOS), so browser upload validation no longer rejects valid files during client->server revalidation. (#23300, #23222, #22848) Thanks @bgaither4, @parkerati, and @Nabsku.
  • -
  • Security/Discord: add openclaw security audit warnings for name/tag-based Discord allowlist entries (DM allowlists, guild/channel users, and pairing-store entries), highlighting slug-collision risk while keeping name-based matching supported, and canonicalize resolved Discord allowlist names to IDs at runtime without rewriting config files. Thanks @tdjackey for reporting.
  • -
  • Security/Gateway: block node-role connections when device identity metadata is missing.
  • -
  • Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Media/Understanding: preserve application/pdf MIME classification during text-like file heuristics so PDF uploads use PDF extraction paths instead of being inlined as raw text. (#23191) Thanks @claudeplay2026-byte.
  • -
  • Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback index.html. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/Gateway avatars: block symlink traversal during local avatar data: URL resolution by enforcing realpath containment and file-identity checks before reads. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/Control UI: centralize avatar URL/path validation across gateway/config helpers and enforce a 2 MB max size for local agent avatar files before /avatar resolution, reducing oversized-avatar memory risk without changing supported avatar formats.
  • -
  • Security/Control UI avatars: harden /avatar/:agentId local avatar serving by rejecting symlink paths and requiring fd-level file identity + size checks before reads. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/MSTeams media: route attachment auth-retry and Graph SharePoint download redirects through shared safeFetch so each hop is validated with allowlist + DNS/IP checks across the full redirect chain. (#23598) Thanks @Asm3r96 and @lewiswigmore.
  • -
  • Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3.
  • -
  • Chat/Usage/TUI: strip synthetic inbound metadata blocks (including Conversation info and trailing Untrusted context channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs.
  • -
  • CI/Tests: fix TypeScript case-table typing and lint assertion regressions so pnpm check passes again after Synology Chat landing. (#23012) Thanks @druide67.
  • -
  • Security/Browser relay: harden extension relay auth token handling for /extension and /cdp pathways.
  • -
  • Cron: persist delivered state in cron job records so delivery failures remain visible in status and logs. (#19174) Thanks @simonemacario.
  • -
  • Config/Doctor: only repair the OAuth credentials directory when affected channels are configured, avoiding fresh-install noise.
  • -
  • Config/Channels: whitelist channels.modelByChannel in config validation and exclude it from plugin auto-enable channel detection so model overrides no longer trigger unknown channel id validation errors or bogus modelByChannel plugin enables. (#23412) Thanks @ProspectOre.
  • -
  • Config/Bindings: allow optional bindings[].comment in strict config validation so annotated binding entries no longer fail load. (#23458) Thanks @echoVic.
  • -
  • Usage/Pricing: correct MiniMax M2.5 pricing defaults to fix inflated cost reporting. (#22755) Thanks @miloudbelarebia.
  • -
  • Gateway/Daemon: verify gateway health after daemon restart.
  • -
  • Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam.
  • +
  • Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (channel/to/thread) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner.
  • +
  • Security/Routing: fail closed for shared-session cross-channel replies by binding outbound target resolution to the current turn’s source channel metadata (instead of stale session route fallbacks), and wire those turn-source fields through gateway + command delivery planners with regression coverage. (#24571) Thanks @brandonwise.
  • +
  • Heartbeat routing: prevent heartbeat leakage/spam into Discord and other direct-message destinations by blocking direct-chat heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871)
  • +
  • Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from last to none (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851)
  • +
  • Auto-reply/Heartbeat queueing: drop heartbeat runs when a session already has an active run instead of enqueueing a stale followup, preventing duplicate heartbeat response branches after queue drain. (#25610, #25606) Thanks @mcaxtr.
  • +
  • Cron/Heartbeat delivery: stop inheriting cached session lastThreadId for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl.
  • +
  • Messaging tool dedupe: treat originating channel metadata as authoritative for same-target message.send suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so delivery-mirror transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch.
  • +
  • Channels/Typing keepalive: refresh channel typing callbacks on a keepalive interval during long replies and clear keepalive timers on idle/cleanup across core + extension dispatcher callsites so typing indicators do not expire mid-inference. (#25886, #25882) Thanks @stakeswky.
  • +
  • Agents/Model fallback: when a run is currently on a configured fallback model, keep traversing the configured fallback chain instead of collapsing straight to primary-only, preventing dead-end failures when primary stays in cooldown. (#25922, #25912) Thanks @Taskle.
  • +
  • Gateway/Models: honor explicit agents.defaults.models allowlist refs even when bundled model catalog data is stale, synthesize missing allowlist entries in models.list, and allow sessions.patch//model selection for those refs without false model not allowed errors. (#20291) Thanks @kensipe, @nikolasdehor, and @vincentkoc.
  • +
  • Control UI/Agents: inherit agents.defaults.model.fallbacks in the Overview fallback input when no per-agent model entry exists, while preserving explicit per-agent fallback overrides (including empty lists). (#25729, #25710) Thanks @Suko.
  • +
  • Automation/Subagent/Cron reliability: honor ANNOUNCE_SKIP in sessions_spawn completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include cron in the coding tool profile so /tools/invoke can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky.
  • +
  • Discord/Voice reliability: restore runtime DAVE dependency (@snazzah/davey), add configurable DAVE join options (channels.discord.voice.daveEncryption and channels.discord.voice.decryptionFailureTolerance), clean up voice listeners/session teardown, guard against stale connection events, and trigger controlled rejoin recovery after repeated decrypt failures to improve inbound STT stability under DAVE receive errors. (#25861, #25372, #24883, #24825, #23890, #23105, #22961, #23421, #23278, #23032)
  • +
  • Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all block payloads), fixing missing Discord replies in channels.discord.streaming=block mode. (#25839, #25836, #25792) Thanks @pewallin.
  • +
  • Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire messages.statusReactions.{emojis,timing} into Discord reaction lifecycle control, and compact model-picker custom_id keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr.
  • +
  • WhatsApp/Web reconnect: treat close status 440 as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson.
  • +
  • WhatsApp/Reasoning safety: suppress outbound payloads marked as reasoning and hard-drop text payloads that begin with Reasoning: before WhatsApp delivery, preventing hidden thinking blocks from leaking to end users through final-message paths. (#25804, #25214, #24328)
  • +
  • Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall.
  • +
  • Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg.
  • +
  • Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg.
  • +
  • Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram autoSelectFamily decisions so outbound fetch calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis.
  • +
  • Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko.
  • +
  • Android/Gateway auth: preserve Android gateway auth state across onboarding, use the native client id for operator sessions, retry with shared-token fallback after device-token auth failures, and avoid clearing tokens on transient connect errors.
  • +
  • Slack/DM routing: treat D* channel IDs as direct messages even when Slack sends an incorrect channel_type, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr.
  • +
  • Zalo/Group policy: enforce sender authorization for group messages with groupPolicy + groupAllowFrom (fallback to allowFrom), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. Thanks @tdjackey for reporting.
  • +
  • macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001.
  • +
  • macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl.
  • +
  • macOS/Voice wake routing: default forwarded voice-wake transcripts to the webchat channel (instead of ambiguous last routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18.
  • +
  • macOS/Gateway launch: prefer an available openclaw binary before pnpm/node runtime fallback when resolving local gateway commands, so local startup no longer fails on hosts with broken runtime discovery. (#25512) Thanks @chilu18.
  • +
  • macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
  • +
  • macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos.
  • +
  • Windows/Exec shell selection: prefer PowerShell 7 (pwsh) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing && command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x.
  • +
  • Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 dev=0 stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false Local media path is not safe to read drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng.
  • +
  • iMessage/Reasoning safety: harden iMessage echo suppression with outbound messageId matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb.
  • +
  • Providers/OpenRouter/Auth profiles: bypass auth-profile cooldown/disable windows for OpenRouter, so provider failures no longer put OpenRouter profiles into local cooldown and stale legacy cooldown markers are ignored in fallback and status selection paths. (#25892) Thanks @alexanderatallah for raising this and @vincentkoc for the fix.
  • +
  • Providers/Google reasoning: sanitize invalid negative thinkingBudget payloads for Gemini 3.1 requests by dropping -1 budgets and mapping configured reasoning effort to thinkingLevel, preventing malformed reasoning payloads on google-generative-ai. (#25900)
  • +
  • Providers/SiliconFlow: normalize thinking="off" to thinking: null for Pro/* model payloads to avoid provider-side 400 loops and misleading compaction retries. (#25435) Thanks @Zjianru.
  • +
  • Models/Bedrock auth: normalize additional Bedrock provider aliases (bedrock, aws-bedrock, aws_bedrock, amazon bedrock) to canonical amazon-bedrock, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13.
  • +
  • Models/Providers: preserve explicit user reasoning overrides when merging provider model config with built-in catalog metadata, so reasoning: false is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728.
  • +
  • Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false pairing required failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.
  • +
  • CLI/Memory search: accept --query for openclaw memory search (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky.
  • +
  • CLI/Doctor: correct stale recovery hints to use valid commands (openclaw gateway status --deep and openclaw configure --section model). (#24485) Thanks @chilu18.
  • +
  • Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr.
  • +
  • Doctor/Plugins: auto-enable now resolves third-party channel plugins by manifest plugin id (not channel id), preventing invalid plugins.entries. writes when ids differ. (#25275) Thanks @zerone0x.
  • +
  • Config/Plugins: treat stale removed google-antigravity-auth plugin references as compatibility warnings (not hard validation errors) across plugins.entries, plugins.allow, plugins.deny, and plugins.slots.memory, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18.
  • +
  • Config/Meta: accept numeric meta.lastTouchedAt timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write Date.now() values. (#25491) Thanks @mcaxtr.
  • +
  • Usage accounting: parse Moonshot/Kimi cached_tokens fields (including prompt_tokens_details.cached_tokens) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001.
  • +
  • Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.
  • +
  • Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit status/code/http 402 detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis.
  • +
  • Sessions/Tool-result guard: avoid generating synthetic toolResult entries for assistant turns that ended with stopReason: "aborted" or "error", preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell.
  • +
  • Auto-reply/Reset hooks: guarantee native /new and /reset flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18.
  • +
  • Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi.
  • +
  • Sandbox/FS bridge tests: add regression coverage for dash-leading basenames to confirm sandbox file reads resolve to absolute container paths (and avoid shell-option misdiagnosis for dashed filenames). (#25891) Thanks @albertlieyingadrian.
  • +
  • Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not ; joins) to avoid POSIX sh do; syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility.
  • +
  • Sandbox/Config: preserve dangerouslyAllowReservedContainerTargets and dangerouslyAllowExternalBindSources during sandbox docker config resolution so explicit bind-mount break-glass overrides reach runtime validation. (#25410) Thanks @skyer-jian.
  • +
  • Gateway/Security: enforce gateway auth for the exact /api/channels plugin root path (plus /api/channels/ descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3.
  • +
  • Exec approvals: treat bare allowlist * as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.
  • +
  • iOS/Signing: improve scripts/ios-team-id.sh for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode xcodebuild output directories (apps/ios/build, apps/shared/OpenClawKit/build, Swabble/build). (#22773) Thanks @brianleach.
  • +
  • Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd.
  • +
  • Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (LD_*, DYLD_*, SSLKEYLOGFILE, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3.
  • +
  • Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width HOOK:...) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3.
  • +
  • Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3.
  • +
  • Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host os.tmpdir() trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting.
  • +
  • Security/Sandbox media: reject hard-linked OpenClaw tmp media aliases (including symlink-to-hardlink chains) during sandbox media path resolution to prevent out-of-sandbox inode alias reads. (#25820) Thanks @bmendonca3.
  • +
  • Security/Message actions: enforce local media root checks for sendAttachment and setGroupIcon when sandboxRoot is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. Thanks @GCXWLP for reporting.
  • +
  • Security/Telegram: enforce DM authorization before media download/write (including media groups) and move telegram inbound activity tracking after DM authorization, preventing unauthorized sender-triggered inbound media disk writes. Thanks @v8hid for reporting.
  • +
  • Security/Workspace FS: normalize @-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. Thanks @tdjackey for reporting.
  • +
  • Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so dmPolicy: "allowlist" with empty allowedUserIds rejects all senders instead of allowing unauthorized dispatch. (#25827) Thanks @bmendonca3 for the contribution and @tdjackey for reporting.
  • +
  • Security/Native images: enforce tools.fs.workspaceOnly for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. Thanks @tdjackey for reporting.
  • +
  • Security/Exec approvals: bind system.run command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only rawCommand mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. Thanks @tdjackey for reporting.
  • +
  • Security/Exec companion host: forward canonical system.run display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting.
  • +
  • Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested /usr/bin/env chains cannot bypass shell-wrapper approval gating in allowlist + ask=on-miss mode. Thanks @tdjackey for reporting.
  • +
  • Security/Exec: limit default safe-bin trusted directories to immutable system paths (/bin, /usr/bin) and require explicit opt-in (tools.exec.safeBinTrustedDirs) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured safeBins resolve outside trusted dirs. Thanks @tdjackey for reporting.
  • +
  • Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.

View full changelog

]]> - +
\ No newline at end of file From 8930dc0a7b09148c86fb7cd7eba3633399837be6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 03:01:48 +0000 Subject: [PATCH 368/408] build: update 2026.2.24 appcast enclosure to beta tag --- appcast.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appcast.xml b/appcast.xml index 902d60972fd7..788e15abd593 100644 --- a/appcast.xml +++ b/appcast.xml @@ -308,7 +308,7 @@

View full changelog

]]> - + \ No newline at end of file From 51d76eb13a24c0ebd95abd95745693a3cf7f85af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 03:30:56 +0000 Subject: [PATCH 369/408] build: switch 2026.2.24 appcast enclosure to stable tag --- appcast.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appcast.xml b/appcast.xml index 788e15abd593..902d60972fd7 100644 --- a/appcast.xml +++ b/appcast.xml @@ -308,7 +308,7 @@

View full changelog

]]> - + \ No newline at end of file From 480cc4b85c0f55b0ef899e4b5ee1fecdaec7df4d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 03:35:33 +0000 Subject: [PATCH 370/408] chore: roll to 2026.2.25 unreleased --- AGENTS.md | 1 + CHANGELOG.md | 8 +++++++- apps/android/app/build.gradle.kts | 4 ++-- apps/ios/Sources/Info.plist | 4 ++-- apps/ios/Tests/Info.plist | 4 ++-- apps/macos/Sources/OpenClaw/Resources/Info.plist | 4 ++-- docs/platforms/mac/release.md | 14 +++++++------- package.json | 2 +- 8 files changed, 24 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 00ae79a05514..09ed6423ac46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,6 +207,7 @@ - launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`. - For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping. - Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step. +- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked. ## NPM + 1Password (publish/verify) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3af54500f2f..0b9d8f57b120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,13 @@ Docs: https://docs.openclaw.ai -## 2026.2.24 (Unreleased) +## 2026.2.25 (Unreleased) + +### Changes + +### Fixes + +## 2026.2.24 ### Changes diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index ad3718b1138c..15d4d15249d7 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "ai.openclaw.android" minSdk = 31 targetSdk = 36 - versionCode = 202602230 - versionName = "2026.2.24" + versionCode = 202602250 + versionName = "2026.2.25" 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/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 28633cc370b1..bcb8c251a02a 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.24 + 2026.2.25 CFBundleURLTypes @@ -32,7 +32,7 @@ CFBundleVersion - 20260223 + 20260225 NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index 2dca88f97f1a..c273b1923d13 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,8 +17,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.2.24 + 2026.2.25 CFBundleVersion - 20260223 + 20260225 diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index 02928da0eb8f..5abb959dc8e8 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.24 + 2026.2.25 CFBundleVersion - 202602230 + 202602250 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 61180e77aab0..db673765c042 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -34,17 +34,17 @@ Notes: # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.24 \ +APP_VERSION=2026.2.25 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-app.sh # Zip for distribution (includes resource forks for Sparkle delta support) -ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.24.zip +ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.25.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.24.dmg +scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.25.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: @@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.24.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.24 \ +APP_VERSION=2026.2.25 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh # Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.24.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.25.dSYM.zip ``` ## Appcast entry @@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl Use the release note generator so Sparkle renders formatted HTML notes: ```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.24.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml +SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.25.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml ``` Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. @@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when ## Publish & verify -- Upload `OpenClaw-2026.2.24.zip` (and `OpenClaw-2026.2.24.dSYM.zip`) to the GitHub release for tag `v2026.2.24`. +- Upload `OpenClaw-2026.2.25.zip` (and `OpenClaw-2026.2.25.dSYM.zip`) to the GitHub release for tag `v2026.2.25`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`. - Sanity checks: - `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200. diff --git a/package.json b/package.json index b04f08ea3c93..81a8a66cb4b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.2.24", + "version": "2026.2.25", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", From d2597d5ecf2ef88fa88d1535db278dcf7aa8bf33 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 03:46:34 +0000 Subject: [PATCH 371/408] fix(agents): harden model fallback failover paths --- CHANGELOG.md | 2 + src/agents/model-fallback.test.ts | 68 ++++++++++++++++- src/agents/model-fallback.ts | 8 +- ...dded-helpers.isbillingerrormessage.test.ts | 6 ++ src/agents/pi-embedded-helpers/errors.ts | 2 + ...ded-pi-agent.auth-profile-rotation.test.ts | 75 +++++++++++++++++++ src/agents/pi-embedded-runner/run.ts | 10 ++- .../reply/agent-runner-utils.test.ts | 19 ++++- src/auto-reply/reply/agent-runner-utils.ts | 6 +- src/auto-reply/reply/followup-runner.ts | 2 +- 10 files changed, 187 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b9d8f57b120..27a9e6cce893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) + ## 2026.2.24 ### Changes diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index f727ea5e9251..903c292ec1ef 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -8,7 +8,7 @@ import type { AuthProfileStore } from "./auth-profiles.js"; import { saveAuthProfileStore } from "./auth-profiles.js"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; import { isAnthropicBillingError } from "./live-auth-keys.js"; -import { runWithModelFallback } from "./model-fallback.js"; +import { runWithImageModelFallback, runWithModelFallback } from "./model-fallback.js"; import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js"; const makeCfg = makeModelFallbackCfg; @@ -581,6 +581,39 @@ describe("runWithModelFallback", () => { expect(calls).toEqual([{ provider: "anthropic", model: "claude-opus-4-5" }]); }); + it("keeps explicit fallbacks reachable when models allowlist is present", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-sonnet-4", + fallbacks: ["openai/gpt-4o", "ollama/llama-3"], + }, + models: { + "anthropic/claude-sonnet-4": {}, + }, + }, + }, + }); + const run = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 })) + .mockResolvedValueOnce("ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-sonnet-4", + run, + }); + + expect(result.result).toBe("ok"); + expect(run.mock.calls).toEqual([ + ["anthropic", "claude-sonnet-4"], + ["openai", "gpt-4o"], + ]); + }); + it("defaults provider/model when missing (regression #946)", async () => { const cfg = makeCfg({ agents: { @@ -721,6 +754,39 @@ describe("runWithModelFallback", () => { }); }); +describe("runWithImageModelFallback", () => { + it("keeps explicit image fallbacks reachable when models allowlist is present", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + imageModel: { + primary: "openai/gpt-image-1", + fallbacks: ["google/gemini-2.5-flash-image-preview"], + }, + models: { + "openai/gpt-image-1": {}, + }, + }, + }, + }); + const run = vi + .fn() + .mockRejectedValueOnce(new Error("rate limited")) + .mockResolvedValueOnce("ok"); + + const result = await runWithImageModelFallback({ + cfg, + run, + }); + + expect(result.result).toBe("ok"); + expect(run.mock.calls).toEqual([ + ["openai", "gpt-image-1"], + ["google", "gemini-2.5-flash-image-preview"], + ]); + }); +}); + describe("isAnthropicBillingError", () => { it("does not false-positive on plain 'a 402' prose", () => { const samples = [ diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index fc44165e0b24..240668ecca25 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -164,7 +164,9 @@ function resolveImageFallbackCandidates(params: { const imageFallbacks = resolveAgentModelFallbackValues(params.cfg?.agents?.defaults?.imageModel); for (const raw of imageFallbacks) { - addRaw(raw, true); + // Explicitly configured image fallbacks should remain reachable even when a + // model allowlist is present. + addRaw(raw, false); } return candidates; @@ -235,7 +237,9 @@ function resolveFallbackCandidates(params: { if (!resolved) { continue; } - addCandidate(resolved.ref, true); + // Fallbacks are explicit user intent; do not silently filter them by the + // model allowlist. + addCandidate(resolved.ref, false); } if (params.fallbacksOverride === undefined && primary?.provider && primary.model) { diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 97b96727562a..638b6c24bb82 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -433,6 +433,12 @@ describe("classifyFailoverReason", () => { expect(classifyFailoverReason("Missing scopes: model.request")).toBe("auth"); expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit"); expect(classifyFailoverReason("resource has been exhausted")).toBe("rate_limit"); + expect( + classifyFailoverReason("model_cooldown: All credentials for model gpt-5 are cooling down"), + ).toBe("rate_limit"); + expect(classifyFailoverReason("all credentials for model x are cooling down")).toBe( + "rate_limit", + ); expect( classifyFailoverReason( '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 29fcfea2c7d2..6eea521ede17 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -615,6 +615,8 @@ type ErrorPattern = RegExp | string; const ERROR_PATTERNS = { rateLimit: [ /rate[_ ]limit|too many requests|429/, + "model_cooldown", + "cooling down", "exceeded your current quota", "resource has been exhausted", "quota exceeded", diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts index b254df7430be..ca66ad4c7f7a 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts @@ -109,6 +109,45 @@ const makeConfig = (opts?: { fallbacks?: string[]; apiKey?: string }): OpenClawC }, }) satisfies OpenClawConfig; +const makeAgentOverrideOnlyFallbackConfig = (agentId: string): OpenClawConfig => + ({ + agents: { + defaults: { + model: { + fallbacks: [], + }, + }, + list: [ + { + id: agentId, + model: { + fallbacks: ["openai/mock-2"], + }, + }, + ], + }, + models: { + providers: { + openai: { + api: "openai-responses", + apiKey: "sk-test", + baseUrl: "https://example.com", + models: [ + { + id: "mock-1", + name: "Mock 1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 16_000, + maxTokens: 2048, + }, + ], + }, + }, + }, + }) satisfies OpenClawConfig; + const writeAuthStore = async ( agentDir: string, opts?: { @@ -516,6 +555,42 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); }); + it("treats agent-level fallbacks as configured when defaults have none", async () => { + await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => { + await writeAuthStore(agentDir, { + usageStats: { + "openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 }, + "openai:p2": { lastUsed: 2, cooldownUntil: now + 60 * 60 * 1000 }, + }, + }); + + await expect( + runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: "agent:support:cooldown-failover", + sessionFile: path.join(workspaceDir, "session.jsonl"), + workspaceDir, + agentDir, + config: makeAgentOverrideOnlyFallbackConfig("support"), + prompt: "hello", + provider: "openai", + model: "mock-1", + authProfileIdSource: "auto", + timeoutMs: 5_000, + runId: "run:agent-override-fallback", + agentId: "support", + }), + ).rejects.toMatchObject({ + name: "FailoverError", + reason: "rate_limit", + provider: "openai", + model: "mock-1", + }); + + expect(runEmbeddedAttemptMock).not.toHaveBeenCalled(); + }); + }); + it("fails over with disabled reason when all profiles are unavailable", async () => { await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => { await writeAuthStore(agentDir, { diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 0ed2ba14d65a..7a3cd76297e7 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -8,6 +8,7 @@ import type { PluginHookBeforeAgentStartResult } from "../../plugins/types.js"; import { enqueueCommandInLane } from "../../process/command-queue.js"; import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; import { resolveOpenClawAgentDir } from "../agent-paths.js"; +import { resolveAgentModelFallbacksOverride } from "../agent-scope.js"; import { isProfileInCooldown, markAuthProfileFailure, @@ -231,8 +232,15 @@ export async function runEmbeddedPiAgent( let provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; let modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); + const agentFallbacksOverride = + params.config && params.agentId + ? resolveAgentModelFallbacksOverride(params.config, params.agentId) + : undefined; const fallbackConfigured = - resolveAgentModelFallbackValues(params.config?.agents?.defaults?.model).length > 0; + ( + agentFallbacksOverride ?? + resolveAgentModelFallbackValues(params.config?.agents?.defaults?.model) + ).length > 0; await ensureOpenClawModelsJson(params.config, agentDir); // Run before_model_resolve hooks early so plugins can override the diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 397cefcb82ae..1476b1f65a24 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -61,10 +61,10 @@ describe("agent-runner-utils", () => { const resolved = resolveModelFallbackOptions(run); - expect(hoisted.resolveAgentIdFromSessionKeyMock).toHaveBeenCalledWith(run.sessionKey); + expect(hoisted.resolveAgentIdFromSessionKeyMock).not.toHaveBeenCalled(); expect(hoisted.resolveAgentModelFallbacksOverrideMock).toHaveBeenCalledWith( run.config, - "agent-id", + run.agentId, ); expect(resolved).toEqual({ cfg: run.config, @@ -75,6 +75,21 @@ describe("agent-runner-utils", () => { }); }); + it("falls back to sessionKey agent id when run.agentId is missing", () => { + hoisted.resolveAgentIdFromSessionKeyMock.mockReturnValue("agent-from-session-key"); + hoisted.resolveAgentModelFallbacksOverrideMock.mockReturnValue(["fallback-model"]); + const run = makeRun({ agentId: undefined }); + + const resolved = resolveModelFallbackOptions(run); + + expect(hoisted.resolveAgentIdFromSessionKeyMock).toHaveBeenCalledWith(run.sessionKey); + expect(hoisted.resolveAgentModelFallbacksOverrideMock).toHaveBeenCalledWith( + run.config, + "agent-from-session-key", + ); + expect(resolved.fallbacksOverride).toEqual(["fallback-model"]); + }); + it("builds embedded run base params with auth profile and run metadata", () => { const run = makeRun({ enforceFinalTag: true }); const authProfile = resolveProviderScopedAuthProfile({ diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index c3902010c5ba..3ec5c27566b1 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -147,15 +147,13 @@ export const resolveEnforceFinalTag = (run: FollowupRun["run"], provider: string Boolean(run.enforceFinalTag || isReasoningTagProvider(provider)); export function resolveModelFallbackOptions(run: FollowupRun["run"]) { + const fallbackAgentId = run.agentId ?? resolveAgentIdFromSessionKey(run.sessionKey); return { cfg: run.config, provider: run.provider, model: run.model, agentDir: run.agentDir, - fallbacksOverride: resolveAgentModelFallbacksOverride( - run.config, - resolveAgentIdFromSessionKey(run.sessionKey), - ), + fallbacksOverride: resolveAgentModelFallbacksOverride(run.config, fallbackAgentId), }; } diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index a73c5f0b0160..872fc8cebb7b 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -135,7 +135,7 @@ export function createFollowupRunner(params: { agentDir: queued.run.agentDir, fallbacksOverride: resolveAgentModelFallbacksOverride( queued.run.config, - resolveAgentIdFromSessionKey(queued.run.sessionKey), + queued.run.agentId ?? resolveAgentIdFromSessionKey(queued.run.sessionKey), ), run: (provider, model) => { const authProfile = resolveRunAuthProfile(queued.run, provider); From 696902702573e645349dddc0478ee419448d4570 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 08:53:00 +0530 Subject: [PATCH 372/408] fix(android): restore chat text streaming --- .../openclaw/android/chat/ChatController.kt | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt index 3ed69ee5b243..b72357983d5a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt @@ -325,6 +325,12 @@ class ChatController( val state = payload["state"].asStringOrNull() when (state) { + "delta" -> { + val text = parseAssistantDeltaText(payload) + if (!text.isNullOrEmpty()) { + _streamingAssistantText.value = text + } + } "final", "aborted", "error" -> { if (state == "error") { _errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed" @@ -351,9 +357,8 @@ class ChatController( private fun handleAgentEvent(payloadJson: String) { val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return - val runId = payload["runId"].asStringOrNull() - val sessionId = _sessionId.value - if (sessionId != null && runId != sessionId) return + val sessionKey = payload["sessionKey"].asStringOrNull()?.trim() + if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return val stream = payload["stream"].asStringOrNull() val data = payload["data"].asObjectOrNull() @@ -398,6 +403,21 @@ class ChatController( } } + private fun parseAssistantDeltaText(payload: JsonObject): String? { + val message = payload["message"].asObjectOrNull() ?: return null + if (message["role"].asStringOrNull() != "assistant") return null + val content = message["content"].asArrayOrNull() ?: return null + for (item in content) { + val obj = item.asObjectOrNull() ?: continue + if (obj["type"].asStringOrNull() != "text") continue + val text = obj["text"].asStringOrNull() + if (!text.isNullOrEmpty()) { + return text + } + } + return null + } + private fun publishPendingToolCalls() { _pendingToolCalls.value = pendingToolCallsById.values.sortedBy { it.startedAtMs } From ff4dc050cc84ebcfc0b9fe417484ba2a3a8c8fdd Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 09:01:13 +0530 Subject: [PATCH 373/408] feat(android): add gfm chat markdown renderer --- apps/android/app/build.gradle.kts | 5 + .../openclaw/android/ui/chat/ChatMarkdown.kt | 584 ++++++++++++++---- 2 files changed, 480 insertions(+), 109 deletions(-) diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 15d4d15249d7..6466474af2b4 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -126,6 +126,11 @@ dependencies { implementation("androidx.exifinterface:exifinterface:1.4.2") implementation("com.squareup.okhttp3:okhttp:5.3.2") implementation("org.bouncycastle:bcprov-jdk18on:1.83") + implementation("org.commonmark:commonmark:0.27.1") + implementation("org.commonmark:commonmark-ext-autolink:0.27.1") + implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.27.1") + implementation("org.commonmark:commonmark-ext-gfm-tables:0.27.1") + implementation("org.commonmark:commonmark-ext-task-list-items:0.27.1") // CameraX (for node.invoke camera.* parity) implementation("androidx.camera:camera-core:1.5.2") diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt index e5d4def3fd98..e121212529a9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt @@ -3,10 +3,20 @@ package ai.openclaw.android.ui.chat import android.graphics.BitmapFactory import android.util.Base64 import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,18 +25,23 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ai.openclaw.android.ui.mobileAccent import ai.openclaw.android.ui.mobileCallout import ai.openclaw.android.ui.mobileCaption1 import ai.openclaw.android.ui.mobileCodeBg @@ -34,159 +49,510 @@ import ai.openclaw.android.ui.mobileCodeText import ai.openclaw.android.ui.mobileTextSecondary import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.commonmark.Extension +import org.commonmark.ext.autolink.AutolinkExtension +import org.commonmark.ext.gfm.strikethrough.Strikethrough +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TableBlock +import org.commonmark.ext.gfm.tables.TableBody +import org.commonmark.ext.gfm.tables.TableCell +import org.commonmark.ext.gfm.tables.TableHead +import org.commonmark.ext.gfm.tables.TableRow +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.ext.task.list.items.TaskListItemMarker +import org.commonmark.ext.task.list.items.TaskListItemsExtension +import org.commonmark.node.BlockQuote +import org.commonmark.node.BulletList +import org.commonmark.node.Code +import org.commonmark.node.Document +import org.commonmark.node.Emphasis +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.Heading +import org.commonmark.node.HardLineBreak +import org.commonmark.node.HtmlBlock +import org.commonmark.node.HtmlInline +import org.commonmark.node.Image as MarkdownImage +import org.commonmark.node.IndentedCodeBlock +import org.commonmark.node.Link +import org.commonmark.node.ListItem +import org.commonmark.node.Node +import org.commonmark.node.OrderedList +import org.commonmark.node.Paragraph +import org.commonmark.node.SoftLineBreak +import org.commonmark.node.StrongEmphasis +import org.commonmark.node.Text as MarkdownTextNode +import org.commonmark.node.ThematicBreak +import org.commonmark.parser.Parser + +private const val LIST_INDENT_DP = 14 +private val dataImageRegex = Regex("^data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)$") + +private val markdownParser: Parser by lazy { + val extensions: List = + listOf( + AutolinkExtension.create(), + StrikethroughExtension.create(), + TablesExtension.create(), + TaskListItemsExtension.create(), + ) + Parser.builder() + .extensions(extensions) + .build() +} @Composable fun ChatMarkdown(text: String, textColor: Color) { - val blocks = remember(text) { splitMarkdown(text) } - val inlineCodeBg = mobileCodeBg - val inlineCodeColor = mobileCodeText + val document = remember(text) { markdownParser.parse(text) as Document } + val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText) Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - for (b in blocks) { - when (b) { - is ChatMarkdownBlock.Text -> { - val trimmed = b.text.trimEnd() - if (trimmed.isEmpty()) continue - Text( - text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor), - style = mobileCallout, - color = textColor, - ) + RenderMarkdownBlocks( + start = document.firstChild, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = 0, + ) + } +} + +@Composable +private fun RenderMarkdownBlocks( + start: Node?, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, +) { + var node = start + while (node != null) { + val current = node + when (current) { + is Paragraph -> { + RenderParagraph(current, textColor = textColor, inlineStyles = inlineStyles) + } + is Heading -> { + val headingText = remember(current) { buildInlineMarkdown(current.firstChild, inlineStyles) } + Text( + text = headingText, + style = headingStyle(current.level), + color = textColor, + ) + } + is FencedCodeBlock -> { + SelectionContainer(modifier = Modifier.fillMaxWidth()) { + ChatCodeBlock(code = current.literal.orEmpty(), language = current.info?.trim()?.ifEmpty { null }) + } + } + is IndentedCodeBlock -> { + SelectionContainer(modifier = Modifier.fillMaxWidth()) { + ChatCodeBlock(code = current.literal.orEmpty(), language = null) } - is ChatMarkdownBlock.Code -> { - SelectionContainer(modifier = Modifier.fillMaxWidth()) { - ChatCodeBlock(code = b.code, language = b.language) + } + is BlockQuote -> { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .background(mobileTextSecondary.copy(alpha = 0.35f)), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + RenderMarkdownBlocks( + start = current.firstChild, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) } } - is ChatMarkdownBlock.InlineImage -> { - InlineBase64Image(base64 = b.base64, mimeType = b.mimeType) + } + is BulletList -> { + RenderBulletList( + list = current, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + } + is OrderedList -> { + RenderOrderedList( + list = current, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + } + is TableBlock -> { + RenderTableBlock( + table = current, + textColor = textColor, + inlineStyles = inlineStyles, + ) + } + is ThematicBreak -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(mobileTextSecondary.copy(alpha = 0.25f)), + ) + } + is HtmlBlock -> { + val literal = current.literal.orEmpty().trim() + if (literal.isNotEmpty()) { + Text( + text = literal, + style = mobileCallout.copy(fontFamily = FontFamily.Monospace), + color = textColor, + ) } } } + node = current.next } } -private sealed interface ChatMarkdownBlock { - data class Text(val text: String) : ChatMarkdownBlock - data class Code(val code: String, val language: String?) : ChatMarkdownBlock - data class InlineImage(val mimeType: String?, val base64: String) : ChatMarkdownBlock -} +@Composable +private fun RenderParagraph( + paragraph: Paragraph, + textColor: Color, + inlineStyles: InlineStyles, +) { + val standaloneImage = remember(paragraph) { standaloneDataImage(paragraph) } + if (standaloneImage != null) { + InlineBase64Image(base64 = standaloneImage.base64, mimeType = standaloneImage.mimeType) + return + } -private fun splitMarkdown(raw: String): List { - if (raw.isEmpty()) return emptyList() + val annotated = remember(paragraph) { buildInlineMarkdown(paragraph.firstChild, inlineStyles) } + if (annotated.text.trimEnd().isEmpty()) { + return + } - val out = ArrayList() - var idx = 0 - while (idx < raw.length) { - val fenceStart = raw.indexOf("```", startIndex = idx) - if (fenceStart < 0) { - out.addAll(splitInlineImages(raw.substring(idx))) - break - } + Text( + text = annotated, + style = mobileCallout, + color = textColor, + ) +} - if (fenceStart > idx) { - out.addAll(splitInlineImages(raw.substring(idx, fenceStart))) +@Composable +private fun RenderBulletList( + list: BulletList, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, +) { + Column( + modifier = Modifier.padding(start = (LIST_INDENT_DP * listDepth).dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + var item = list.firstChild + while (item != null) { + if (item is ListItem) { + RenderListItem( + item = item, + markerText = "•", + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + } + item = item.next } + } +} - val langLineStart = fenceStart + 3 - val langLineEnd = raw.indexOf('\n', startIndex = langLineStart).let { if (it < 0) raw.length else it } - val language = raw.substring(langLineStart, langLineEnd).trim().ifEmpty { null } - - val codeStart = if (langLineEnd < raw.length && raw[langLineEnd] == '\n') langLineEnd + 1 else langLineEnd - val fenceEnd = raw.indexOf("```", startIndex = codeStart) - if (fenceEnd < 0) { - out.addAll(splitInlineImages(raw.substring(fenceStart))) - break +@Composable +private fun RenderOrderedList( + list: OrderedList, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, +) { + Column( + modifier = Modifier.padding(start = (LIST_INDENT_DP * listDepth).dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + var index = list.markerStartNumber ?: 1 + var item = list.firstChild + while (item != null) { + if (item is ListItem) { + RenderListItem( + item = item, + markerText = "$index.", + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + index += 1 + } + item = item.next } - val code = raw.substring(codeStart, fenceEnd) - out.add(ChatMarkdownBlock.Code(code = code, language = language)) + } +} - idx = fenceEnd + 3 +@Composable +private fun RenderListItem( + item: ListItem, + markerText: String, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, +) { + var contentStart = item.firstChild + var marker = markerText + val task = contentStart as? TaskListItemMarker + if (task != null) { + marker = if (task.isChecked) "☑" else "☐" + contentStart = task.next } - return out + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = marker, + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = textColor, + modifier = Modifier.width(24.dp), + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + RenderMarkdownBlocks( + start = contentStart, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth + 1, + ) + } + } } -private fun splitInlineImages(text: String): List { - if (text.isEmpty()) return emptyList() - val regex = Regex("data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)") - val out = ArrayList() +@Composable +private fun RenderTableBlock( + table: TableBlock, + textColor: Color, + inlineStyles: InlineStyles, +) { + val rows = remember(table) { buildTableRows(table, inlineStyles) } + if (rows.isEmpty()) return + + val maxCols = rows.maxOf { row -> row.cells.size }.coerceAtLeast(1) + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(scrollState) + .border(1.dp, mobileTextSecondary.copy(alpha = 0.25f)), + ) { + for (row in rows) { + Row( + modifier = Modifier.fillMaxWidth(), + ) { + for (index in 0 until maxCols) { + val cell = row.cells.getOrNull(index) ?: AnnotatedString("") + Text( + text = cell, + style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else mobileCallout, + color = textColor, + modifier = Modifier + .border(1.dp, mobileTextSecondary.copy(alpha = 0.22f)) + .padding(horizontal = 8.dp, vertical = 6.dp) + .width(160.dp), + ) + } + } + } + } +} - var idx = 0 - while (idx < text.length) { - val m = regex.find(text, startIndex = idx) ?: break - val start = m.range.first - val end = m.range.last + 1 - if (start > idx) out.add(ChatMarkdownBlock.Text(text.substring(idx, start))) +private fun buildTableRows(table: TableBlock, inlineStyles: InlineStyles): List { + val rows = mutableListOf() + var child = table.firstChild + while (child != null) { + when (child) { + is TableHead -> rows.addAll(readTableSection(child, isHeader = true, inlineStyles = inlineStyles)) + is TableBody -> rows.addAll(readTableSection(child, isHeader = false, inlineStyles = inlineStyles)) + is TableRow -> rows.add(readTableRow(child, isHeader = false, inlineStyles = inlineStyles)) + } + child = child.next + } + return rows +} - val mime = "image/" + (m.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png") - val b64 = m.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty() - if (b64.isNotEmpty()) { - out.add(ChatMarkdownBlock.InlineImage(mimeType = mime, base64 = b64)) +private fun readTableSection(section: Node, isHeader: Boolean, inlineStyles: InlineStyles): List { + val rows = mutableListOf() + var row = section.firstChild + while (row != null) { + if (row is TableRow) { + rows.add(readTableRow(row, isHeader = isHeader, inlineStyles = inlineStyles)) } - idx = end + row = row.next } + return rows +} - if (idx < text.length) out.add(ChatMarkdownBlock.Text(text.substring(idx))) - return out +private fun readTableRow(row: TableRow, isHeader: Boolean, inlineStyles: InlineStyles): TableRenderRow { + val cells = mutableListOf() + var cellNode = row.firstChild + while (cellNode != null) { + if (cellNode is TableCell) { + cells.add(buildInlineMarkdown(cellNode.firstChild, inlineStyles)) + } + cellNode = cellNode.next + } + return TableRenderRow(isHeader = isHeader, cells = cells) } -private fun parseInlineMarkdown( - text: String, - inlineCodeBg: androidx.compose.ui.graphics.Color, - inlineCodeColor: androidx.compose.ui.graphics.Color, -): AnnotatedString { - if (text.isEmpty()) return AnnotatedString("") +private fun buildInlineMarkdown(start: Node?, inlineStyles: InlineStyles): AnnotatedString { + return buildAnnotatedString { + appendInlineNode( + node = start, + inlineCodeBg = inlineStyles.inlineCodeBg, + inlineCodeColor = inlineStyles.inlineCodeColor, + ) + } +} - val out = buildAnnotatedString { - var i = 0 - while (i < text.length) { - if (text.startsWith("**", startIndex = i)) { - val end = text.indexOf("**", startIndex = i + 2) - if (end > i + 2) { - withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { - append(text.substring(i + 2, end)) - } - i = end + 2 - continue +private fun AnnotatedString.Builder.appendInlineNode( + node: Node?, + inlineCodeBg: Color, + inlineCodeColor: Color, +) { + var current = node + while (current != null) { + when (current) { + is MarkdownTextNode -> append(current.literal) + is SoftLineBreak -> append('\n') + is HardLineBreak -> append('\n') + is Code -> { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + background = inlineCodeBg, + color = inlineCodeColor, + ), + ) { + append(current.literal) } } - - if (text[i] == '`') { - val end = text.indexOf('`', startIndex = i + 1) - if (end > i + 1) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - background = inlineCodeBg, - color = inlineCodeColor, - ), - ) { - append(text.substring(i + 1, end)) - } - i = end + 1 - continue + is Emphasis -> { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) } } - - if (text[i] == '*' && (i + 1 < text.length && text[i + 1] != '*')) { - val end = text.indexOf('*', startIndex = i + 1) - if (end > i + 1) { - withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - append(text.substring(i + 1, end)) - } - i = end + 1 - continue + is StrongEmphasis -> { + withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) + } + } + is Strikethrough -> { + withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) + } + } + is Link -> { + withStyle( + SpanStyle( + color = mobileAccent, + textDecoration = TextDecoration.Underline, + ), + ) { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) } } + is MarkdownImage -> { + val alt = buildPlainText(current.firstChild) + if (alt.isNotBlank()) { + append(alt) + } else { + append("image") + } + } + is HtmlInline -> { + if (!current.literal.isNullOrBlank()) { + append(current.literal) + } + } + else -> { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) + } + } + current = current.next + } +} - append(text[i]) - i += 1 +private fun buildPlainText(start: Node?): String { + val sb = StringBuilder() + var node = start + while (node != null) { + when (node) { + is MarkdownTextNode -> sb.append(node.literal) + is SoftLineBreak, is HardLineBreak -> sb.append('\n') + else -> sb.append(buildPlainText(node.firstChild)) } + node = node.next + } + return sb.toString() +} + +private fun standaloneDataImage(paragraph: Paragraph): ParsedDataImage? { + val only = paragraph.firstChild as? MarkdownImage ?: return null + if (only.next != null) return null + return parseDataImageDestination(only.destination) +} + +private fun parseDataImageDestination(destination: String?): ParsedDataImage? { + val raw = destination?.trim().orEmpty() + if (raw.isEmpty()) return null + val match = dataImageRegex.matchEntire(raw) ?: return null + val subtype = match.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png" + val base64 = match.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty() + if (base64.isEmpty()) return null + return ParsedDataImage(mimeType = "image/$subtype", base64 = base64) +} + +private fun headingStyle(level: Int): TextStyle { + return when (level.coerceIn(1, 6)) { + 1 -> mobileCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold) + 2 -> mobileCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold) + 3 -> mobileCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold) + 4 -> mobileCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold) + else -> mobileCallout.copy(fontWeight = FontWeight.SemiBold) } - return out } +private data class InlineStyles( + val inlineCodeBg: Color, + val inlineCodeColor: Color, +) + +private data class TableRenderRow( + val isHeader: Boolean, + val cells: List, +) + +private data class ParsedDataImage( + val mimeType: String, + val base64: String, +) + @Composable private fun InlineBase64Image(base64: String, mimeType: String?) { var image by remember(base64) { mutableStateOf(null) } From 797843c39ab150e1e7cc9747cf7da58402c6b085 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 09:13:20 +0530 Subject: [PATCH 374/408] build(android): bump stable dependencies --- apps/android/app/build.gradle.kts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 6466474af2b4..535de8602a23 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -97,7 +97,7 @@ kotlin { } dependencies { - val composeBom = platform("androidx.compose:compose-bom:2025.12.00") + val composeBom = platform("androidx.compose:compose-bom:2026.02.00") implementation(composeBom) androidTestImplementation(composeBom) @@ -112,7 +112,7 @@ dependencies { // material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used. // R8 will tree-shake unused icons when minify is enabled on release builds. implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.navigation:navigation-compose:2.9.6") + implementation("androidx.navigation:navigation-compose:2.9.7") debugImplementation("androidx.compose.ui:ui-tooling") @@ -120,7 +120,7 @@ dependencies { implementation("com.google.android.material:material:1.13.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") implementation("androidx.security:security-crypto:1.1.0") implementation("androidx.exifinterface:exifinterface:1.4.2") @@ -144,9 +144,9 @@ dependencies { testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") - testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7") - testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7") - testImplementation("org.robolectric:robolectric:4.16") + testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.3") + testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.3") + testImplementation("org.robolectric:robolectric:4.16.1") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2") } From 1edd9f8bf54875a947e55519721f772066f8fcfa Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 09:21:18 +0530 Subject: [PATCH 375/408] build(android): migrate to AGP 9 new DSL kotlin setup --- apps/android/app/build.gradle.kts | 3 +-- apps/android/build.gradle.kts | 1 - apps/android/gradle.properties | 3 +-- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 535de8602a23..ffe7d1d77c30 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -2,7 +2,6 @@ import com.android.build.api.variant.impl.VariantOutputImpl plugins { id("com.android.application") - id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.serialization") } @@ -13,7 +12,7 @@ android { sourceSets { getByName("main") { - assets.srcDir(file("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")) + assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources") } } diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts index 87252db452cc..bea7b46b2c21 100644 --- a/apps/android/build.gradle.kts +++ b/apps/android/build.gradle.kts @@ -1,6 +1,5 @@ plugins { id("com.android.application") version "9.0.1" apply false - id("org.jetbrains.kotlin.android") version "2.2.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false } diff --git a/apps/android/gradle.properties b/apps/android/gradle.properties index 29f08fa7a090..4134274afddb 100644 --- a/apps/android/gradle.properties +++ b/apps/android/gradle.properties @@ -11,5 +11,4 @@ android.uniquePackageNames=false android.dependency.useConstraints=true android.r8.strictFullModeForKeepRules=false android.r8.optimizedResourceShrinking=false -android.builtInKotlin=false -android.newDsl=false +android.newDsl=true From d942e5924efbe8bf69825ba3dee1028e39679e13 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 09:31:41 +0530 Subject: [PATCH 376/408] docs: add changelog entry for #26079 (thanks @obviyus) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27a9e6cce893..a0ec48ff23ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai ### Changes +- Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus. + ### Fixes - Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) From 2652bb1d7dca5987335f492ce9401c4e7e8227aa Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 25 Feb 2026 04:19:59 +0000 Subject: [PATCH 377/408] Release: sync plugin versions to 2026.2.25 --- extensions/bluebubbles/package.json | 2 +- extensions/copilot-proxy/package.json | 2 +- extensions/diagnostics-otel/package.json | 2 +- extensions/discord/package.json | 2 +- extensions/feishu/package.json | 2 +- extensions/google-gemini-cli-auth/package.json | 2 +- extensions/googlechat/package.json | 2 +- extensions/imessage/package.json | 2 +- extensions/irc/package.json | 2 +- extensions/line/package.json | 2 +- extensions/llm-task/package.json | 2 +- extensions/lobster/package.json | 2 +- extensions/matrix/CHANGELOG.md | 6 ++++++ extensions/matrix/package.json | 2 +- extensions/mattermost/package.json | 2 +- extensions/memory-core/package.json | 2 +- extensions/memory-lancedb/package.json | 2 +- extensions/minimax-portal-auth/package.json | 2 +- extensions/msteams/CHANGELOG.md | 6 ++++++ extensions/msteams/package.json | 2 +- extensions/nextcloud-talk/package.json | 2 +- extensions/nostr/CHANGELOG.md | 6 ++++++ extensions/nostr/package.json | 2 +- extensions/open-prose/package.json | 2 +- extensions/signal/package.json | 2 +- extensions/slack/package.json | 2 +- extensions/synology-chat/package.json | 2 +- extensions/telegram/package.json | 2 +- extensions/tlon/package.json | 2 +- extensions/twitch/CHANGELOG.md | 6 ++++++ extensions/twitch/package.json | 2 +- extensions/voice-call/CHANGELOG.md | 6 ++++++ extensions/voice-call/package.json | 2 +- extensions/whatsapp/package.json | 2 +- extensions/zalo/CHANGELOG.md | 6 ++++++ extensions/zalo/package.json | 2 +- extensions/zalouser/CHANGELOG.md | 6 ++++++ extensions/zalouser/package.json | 2 +- 38 files changed, 73 insertions(+), 31 deletions(-) diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index 42621d894b4c..8f752f59350a 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/bluebubbles", - "version": "2026.2.24", + "version": "2026.2.25", "description": "OpenClaw BlueBubbles channel plugin", "type": "module", "openclaw": { diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 45b04cb35353..8dd561f27f3f 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/copilot-proxy", - "version": "2026.2.24", + "version": "2026.2.25", "private": true, "description": "OpenClaw Copilot Proxy provider plugin", "type": "module", diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index 1620812b8e93..32c5ad8275d7 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diagnostics-otel", - "version": "2026.2.24", + "version": "2026.2.25", "description": "OpenClaw diagnostics OpenTelemetry exporter", "type": "module", "dependencies": { diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 72f8b6c4258e..2553b1c08140 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/discord", - "version": "2026.2.24", + "version": "2026.2.25", "description": "OpenClaw Discord channel plugin", "type": "module", "openclaw": { diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 23dcdb772905..afacb5432eb8 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/feishu", - "version": "2026.2.24", + "version": "2026.2.25", "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)", "type": "module", "dependencies": { diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index 736c1a75ab88..9ec1c1af3608 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/google-gemini-cli-auth", - "version": "2026.2.24", + "version": "2026.2.25", "private": true, "description": "OpenClaw Gemini CLI OAuth provider plugin", "type": "module", diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 4828b5cdfb4a..fd43f2faa26a 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/googlechat", - "version": "2026.2.24", + "version": "2026.2.25", "private": true, "description": "OpenClaw Google Chat channel plugin", "type": "module", diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 8d139d6528a7..7eeafd8b8722 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/imessage", - "version": "2026.2.24", + "version": "2026.2.25", "private": true, "description": "OpenClaw iMessage channel plugin", "type": "module", diff --git a/extensions/irc/package.json b/extensions/irc/package.json index acab9d28d0e3..e5937ee763b7 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/irc", - "version": "2026.2.24", + "version": "2026.2.25", "description": "OpenClaw IRC channel plugin", "type": "module", "openclaw": { diff --git a/extensions/line/package.json b/extensions/line/package.json index e0206302e787..402952b084c2 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/line", - "version": "2026.2.24", + "version": "2026.2.25", "private": true, "description": "OpenClaw LINE channel plugin", "type": "module", diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index bade2e1f2e95..9e182b901345 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/llm-task", - "version": "2026.2.24", + "version": "2026.2.25", "private": true, "description": "OpenClaw JSON-only LLM task plugin", "type": "module", diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index 02a60975427f..f60a1ff73a65 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/lobster", - "version": "2026.2.24", + "version": "2026.2.25", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "type": "module", "openclaw": { diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md index a040e7664caa..deffac4088a7 100644 --- a/extensions/matrix/CHANGELOG.md +++ b/extensions/matrix/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.24 ### Changes diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 4499cd032a66..615cbc748552 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/matrix", - "version": "2026.2.24", + "version": "2026.2.25", "description": "OpenClaw Matrix channel plugin", "type": "module", "dependencies": { diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index 8e019aa629d3..b9dfe770ee1c 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/mattermost", - "version": "2026.2.24", + "version": "2026.2.25", "description": "OpenClaw Mattermost channel plugin", "type": "module", "openclaw": { diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index 94fc954dda00..98bdbe76f73a 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-core", - "version": "2026.2.24", + "version": "2026.2.25", "private": true, "description": "OpenClaw core memory search plugin", "type": "module", diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index 0548f4bacec6..a658940881ee 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-lancedb", - "version": "2026.2.24", + "version": "2026.2.25", "private": true, "description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture", "type": "module", diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json index dc55da4d7533..4a0dfc6121da 100644 --- a/extensions/minimax-portal-auth/package.json +++ b/extensions/minimax-portal-auth/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/minimax-portal-auth", - "version": "2026.2.24", + "version": "2026.2.25", "private": true, "description": "OpenClaw MiniMax Portal OAuth provider plugin", "type": "module", diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index 0b76b055c153..b6760627b46f 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.24 ### Changes diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 2dce7475f940..efee0ce85548 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/msteams", - "version": "2026.2.24", + "version": "2026.2.25", "description": "OpenClaw Microsoft Teams channel plugin", "type": "module", "dependencies": { diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index 210bcb0993ed..cd4639b1c0f5 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nextcloud-talk", - "version": "2026.2.24", + "version": "2026.2.25", "description": "OpenClaw Nextcloud Talk channel plugin", "type": "module", "openclaw": { diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md index 9d3667c3c8f9..3ab7bf7a1368 100644 --- a/extensions/nostr/CHANGELOG.md +++ b/extensions/nostr/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.24 ### Changes diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index ffa3cba5c419..72b1a2cee623 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nostr", - "version": "2026.2.24", + "version": "2026.2.25", "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs", "type": "module", "dependencies": { diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index 157f546e2f7f..4d28edc8e68a 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/open-prose", - "version": "2026.2.24", + "version": "2026.2.25", "private": true, "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "type": "module", diff --git a/extensions/signal/package.json b/extensions/signal/package.json index bf82f1e703b3..1005503eff19 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/signal", - "version": "2026.2.24", + "version": "2026.2.25", "private": true, "description": "OpenClaw Signal channel plugin", "type": "module", diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 530a8132082c..adbd311981f8 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/slack", - "version": "2026.2.24", + "version": "2026.2.25", "private": true, "description": "OpenClaw Slack channel plugin", "type": "module", diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index 940ce593aba3..e4474651f07e 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/synology-chat", - "version": "2026.2.24", + "version": "2026.2.25", "description": "Synology Chat channel plugin for OpenClaw", "type": "module", "dependencies": { diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index fa596ad0209a..83586d5da0e9 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/telegram", - "version": "2026.2.24", + "version": "2026.2.25", "private": true, "description": "OpenClaw Telegram channel plugin", "type": "module", diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index ec18f0616fd7..b989fb957a8c 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/tlon", - "version": "2026.2.24", + "version": "2026.2.25", "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { diff --git a/extensions/twitch/CHANGELOG.md b/extensions/twitch/CHANGELOG.md index 4910a82d5c74..94e20c4cf6a6 100644 --- a/extensions/twitch/CHANGELOG.md +++ b/extensions/twitch/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.24 ### Changes diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 54285d122257..1efd4d0814f9 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/twitch", - "version": "2026.2.24", + "version": "2026.2.25", "description": "OpenClaw Twitch channel plugin", "type": "module", "dependencies": { diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index e41b0f6219f7..48f4d2573a0f 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.24 ### Changes diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 56d772b28abd..e09e59fef8d1 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/voice-call", - "version": "2026.2.24", + "version": "2026.2.25", "description": "OpenClaw voice-call plugin", "type": "module", "dependencies": { diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index bed133601b5e..8cabcd7bf57b 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/whatsapp", - "version": "2026.2.24", + "version": "2026.2.25", "private": true, "description": "OpenClaw WhatsApp channel plugin", "type": "module", diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index bf644f83fe77..2cf799f217f1 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.24 ### Changes diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index e5e23bdee20b..3154002f997e 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalo", - "version": "2026.2.24", + "version": "2026.2.25", "description": "OpenClaw Zalo channel plugin", "type": "module", "dependencies": { diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md index cb2dafc5a829..c247e93b967f 100644 --- a/extensions/zalouser/CHANGELOG.md +++ b/extensions/zalouser/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.24 ### Changes diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index f6530d74c931..49cede39b768 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalouser", - "version": "2026.2.24", + "version": "2026.2.25", "description": "OpenClaw Zalo Personal Account plugin via zca-cli", "type": "module", "dependencies": { From dd6ad0da8c3f8ec7b588971494bef87348d1ea25 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 04:29:48 +0000 Subject: [PATCH 378/408] test(exec): stabilize Windows PATH prepend assertion --- src/agents/bash-tools.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index db0a910f2c84..4841038ff304 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -533,7 +533,17 @@ describe("exec PATH handling", () => { const text = readNormalizedTextContent(result.content); const entries = text.split(path.delimiter); - expect(entries.slice(0, prepend.length)).toEqual(prepend); - expect(entries).toContain(basePath); + const prependIndexes = prepend.map((entry) => entries.indexOf(entry)); + for (const index of prependIndexes) { + expect(index).toBeGreaterThanOrEqual(0); + } + for (let i = 1; i < prependIndexes.length; i += 1) { + expect(prependIndexes[i]).toBeGreaterThan(prependIndexes[i - 1]); + } + const baseIndex = entries.indexOf(basePath); + expect(baseIndex).toBeGreaterThanOrEqual(0); + for (const index of prependIndexes) { + expect(index).toBeLessThan(baseIndex); + } }); }); From 9beec48e9c674447a7fc201348f59c4c4c365dad Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 04:32:25 +0000 Subject: [PATCH 379/408] refactor(agents): centralize model fallback resolution --- src/agents/agent-scope.test.ts | 106 ++++++++++++++++++ src/agents/agent-scope.ts | 38 ++++++- src/agents/model-fallback.ts | 37 ++++-- src/agents/pi-embedded-runner/run.ts | 17 +-- .../reply/agent-runner-utils.test.ts | 45 +++----- src/auto-reply/reply/agent-runner-utils.ts | 10 +- src/auto-reply/reply/followup-runner.ts | 13 ++- 7 files changed, 205 insertions(+), 61 deletions(-) diff --git a/src/agents/agent-scope.test.ts b/src/agents/agent-scope.test.ts index f921a1315763..ad4e0f56fd02 100644 --- a/src/agents/agent-scope.test.ts +++ b/src/agents/agent-scope.test.ts @@ -2,13 +2,16 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { + hasConfiguredModelFallbacks, resolveAgentConfig, resolveAgentDir, resolveAgentEffectiveModelPrimary, resolveAgentExplicitModelPrimary, + resolveFallbackAgentId, resolveEffectiveModelFallbacks, resolveAgentModelFallbacksOverride, resolveAgentModelPrimary, + resolveRunModelFallbacksOverride, resolveAgentWorkspaceDir, } from "./agent-scope.js"; @@ -210,6 +213,109 @@ describe("resolveAgentConfig", () => { ).toEqual([]); }); + it("resolves fallback agent id from explicit agent id first", () => { + expect( + resolveFallbackAgentId({ + agentId: "Support", + sessionKey: "agent:main:session", + }), + ).toBe("support"); + }); + + it("resolves fallback agent id from session key when explicit id is missing", () => { + expect( + resolveFallbackAgentId({ + sessionKey: "agent:worker:session", + }), + ).toBe("worker"); + }); + + it("resolves run fallback overrides via shared helper", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-4.1"], + }, + }, + list: [ + { + id: "support", + model: { + fallbacks: ["openai/gpt-5.2"], + }, + }, + ], + }, + }; + + expect( + resolveRunModelFallbacksOverride({ + cfg, + agentId: "support", + sessionKey: "agent:main:session", + }), + ).toEqual(["openai/gpt-5.2"]); + expect( + resolveRunModelFallbacksOverride({ + cfg, + agentId: undefined, + sessionKey: "agent:support:session", + }), + ).toEqual(["openai/gpt-5.2"]); + }); + + it("computes whether any model fallbacks are configured via shared helper", () => { + const cfgDefaultsOnly: OpenClawConfig = { + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-4.1"], + }, + }, + list: [{ id: "main" }], + }, + }; + expect( + hasConfiguredModelFallbacks({ + cfg: cfgDefaultsOnly, + sessionKey: "agent:main:session", + }), + ).toBe(true); + + const cfgAgentOverrideOnly: OpenClawConfig = { + agents: { + defaults: { + model: { + fallbacks: [], + }, + }, + list: [ + { + id: "support", + model: { + fallbacks: ["openai/gpt-5.2"], + }, + }, + ], + }, + }; + expect( + hasConfiguredModelFallbacks({ + cfg: cfgAgentOverrideOnly, + agentId: "support", + sessionKey: "agent:support:session", + }), + ).toBe(true); + expect( + hasConfiguredModelFallbacks({ + cfg: cfgAgentOverrideOnly, + agentId: "main", + sessionKey: "agent:main:session", + }), + ).toBe(false); + }); + it("should return agent-specific sandbox config", () => { const cfg: OpenClawConfig = { agents: { diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 31fe49c0b761..bdc880656969 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -7,6 +7,7 @@ import { DEFAULT_AGENT_ID, normalizeAgentId, parseAgentSessionKey, + resolveAgentIdFromSessionKey, } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; import { normalizeSkillFilter } from "./skills/filter.js"; @@ -19,7 +20,7 @@ function stripNullBytes(s: string): string { return s.replace(/\0/g, ""); } -export { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; +export { resolveAgentIdFromSessionKey }; type AgentEntry = NonNullable["list"]>[number]; @@ -203,6 +204,41 @@ export function resolveAgentModelFallbacksOverride( return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined; } +export function resolveFallbackAgentId(params: { + agentId?: string | null; + sessionKey?: string | null; +}): string { + const explicitAgentId = typeof params.agentId === "string" ? params.agentId.trim() : ""; + if (explicitAgentId) { + return normalizeAgentId(explicitAgentId); + } + return resolveAgentIdFromSessionKey(params.sessionKey); +} + +export function resolveRunModelFallbacksOverride(params: { + cfg: OpenClawConfig | undefined; + agentId?: string | null; + sessionKey?: string | null; +}): string[] | undefined { + if (!params.cfg) { + return undefined; + } + return resolveAgentModelFallbacksOverride( + params.cfg, + resolveFallbackAgentId({ agentId: params.agentId, sessionKey: params.sessionKey }), + ); +} + +export function hasConfiguredModelFallbacks(params: { + cfg: OpenClawConfig | undefined; + agentId?: string | null; + sessionKey?: string | null; +}): boolean { + const fallbacksOverride = resolveRunModelFallbacksOverride(params); + const defaultFallbacks = resolveAgentModelFallbackValues(params.cfg?.agents?.defaults?.model); + return (fallbacksOverride ?? defaultFallbacks).length > 0; +} + export function resolveEffectiveModelFallbacks(params: { cfg: OpenClawConfig; agentId: string; diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 240668ecca25..b75eb8de4bf6 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -63,7 +63,8 @@ function shouldRethrowAbort(err: unknown): boolean { function createModelCandidateCollector(allowlist: Set | null | undefined): { candidates: ModelCandidate[]; - addCandidate: (candidate: ModelCandidate, enforceAllowlist: boolean) => void; + addExplicitCandidate: (candidate: ModelCandidate) => void; + addAllowlistedCandidate: (candidate: ModelCandidate) => void; } { const seen = new Set(); const candidates: ModelCandidate[] = []; @@ -83,7 +84,14 @@ function createModelCandidateCollector(allowlist: Set | null | undefined candidates.push(candidate); }; - return { candidates, addCandidate }; + const addExplicitCandidate = (candidate: ModelCandidate) => { + addCandidate(candidate, false); + }; + const addAllowlistedCandidate = (candidate: ModelCandidate) => { + addCandidate(candidate, true); + }; + + return { candidates, addExplicitCandidate, addAllowlistedCandidate }; } type ModelFallbackErrorHandler = (attempt: { @@ -138,9 +146,10 @@ function resolveImageFallbackCandidates(params: { cfg: params.cfg, defaultProvider: params.defaultProvider, }); - const { candidates, addCandidate } = createModelCandidateCollector(allowlist); + const { candidates, addExplicitCandidate, addAllowlistedCandidate } = + createModelCandidateCollector(allowlist); - const addRaw = (raw: string, enforceAllowlist: boolean) => { + const addRaw = (raw: string, opts?: { allowlist?: boolean }) => { const resolved = resolveModelRefFromString({ raw: String(raw ?? ""), defaultProvider: params.defaultProvider, @@ -149,15 +158,19 @@ function resolveImageFallbackCandidates(params: { if (!resolved) { return; } - addCandidate(resolved.ref, enforceAllowlist); + if (opts?.allowlist) { + addAllowlistedCandidate(resolved.ref); + return; + } + addExplicitCandidate(resolved.ref); }; if (params.modelOverride?.trim()) { - addRaw(params.modelOverride, false); + addRaw(params.modelOverride); } else { const primary = resolveAgentModelPrimaryValue(params.cfg?.agents?.defaults?.imageModel); if (primary?.trim()) { - addRaw(primary, false); + addRaw(primary); } } @@ -166,7 +179,7 @@ function resolveImageFallbackCandidates(params: { for (const raw of imageFallbacks) { // Explicitly configured image fallbacks should remain reachable even when a // model allowlist is present. - addRaw(raw, false); + addRaw(raw); } return candidates; @@ -200,9 +213,9 @@ function resolveFallbackCandidates(params: { cfg: params.cfg, defaultProvider, }); - const { candidates, addCandidate } = createModelCandidateCollector(allowlist); + const { candidates, addExplicitCandidate } = createModelCandidateCollector(allowlist); - addCandidate(normalizedPrimary, false); + addExplicitCandidate(normalizedPrimary); const modelFallbacks = (() => { if (params.fallbacksOverride !== undefined) { @@ -239,11 +252,11 @@ function resolveFallbackCandidates(params: { } // Fallbacks are explicit user intent; do not silently filter them by the // model allowlist. - addCandidate(resolved.ref, false); + addExplicitCandidate(resolved.ref); } if (params.fallbacksOverride === undefined && primary?.provider && primary.model) { - addCandidate({ provider: primary.provider, model: primary.model }, false); + addExplicitCandidate({ provider: primary.provider, model: primary.model }); } return candidates; diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 7a3cd76297e7..06df4cb43512 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -1,14 +1,13 @@ import { randomBytes } from "node:crypto"; import fs from "node:fs/promises"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; -import { resolveAgentModelFallbackValues } from "../../config/model-input.js"; import { generateSecureToken } from "../../infra/secure-random.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import type { PluginHookBeforeAgentStartResult } from "../../plugins/types.js"; import { enqueueCommandInLane } from "../../process/command-queue.js"; import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; import { resolveOpenClawAgentDir } from "../agent-paths.js"; -import { resolveAgentModelFallbacksOverride } from "../agent-scope.js"; +import { hasConfiguredModelFallbacks } from "../agent-scope.js"; import { isProfileInCooldown, markAuthProfileFailure, @@ -232,15 +231,11 @@ export async function runEmbeddedPiAgent( let provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; let modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); - const agentFallbacksOverride = - params.config && params.agentId - ? resolveAgentModelFallbacksOverride(params.config, params.agentId) - : undefined; - const fallbackConfigured = - ( - agentFallbacksOverride ?? - resolveAgentModelFallbackValues(params.config?.agents?.defaults?.model) - ).length > 0; + const fallbackConfigured = hasConfiguredModelFallbacks({ + cfg: params.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + }); await ensureOpenClawModelsJson(params.config, agentDir); // Run before_model_resolve hooks early so plugins can override the diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 1476b1f65a24..350c6b63e47b 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -2,19 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { FollowupRun } from "./queue.js"; const hoisted = vi.hoisted(() => { - const resolveAgentModelFallbacksOverrideMock = vi.fn(); - const resolveAgentIdFromSessionKeyMock = vi.fn(); - return { resolveAgentModelFallbacksOverrideMock, resolveAgentIdFromSessionKeyMock }; + const resolveRunModelFallbacksOverrideMock = vi.fn(); + return { resolveRunModelFallbacksOverrideMock }; }); vi.mock("../../agents/agent-scope.js", () => ({ - resolveAgentModelFallbacksOverride: (...args: unknown[]) => - hoisted.resolveAgentModelFallbacksOverrideMock(...args), -})); - -vi.mock("../../config/sessions.js", () => ({ - resolveAgentIdFromSessionKey: (...args: unknown[]) => - hoisted.resolveAgentIdFromSessionKeyMock(...args), + resolveRunModelFallbacksOverride: (...args: unknown[]) => + hoisted.resolveRunModelFallbacksOverrideMock(...args), })); const { @@ -50,22 +44,20 @@ function makeRun(overrides: Partial = {}): FollowupRun["run" describe("agent-runner-utils", () => { beforeEach(() => { - hoisted.resolveAgentModelFallbacksOverrideMock.mockClear(); - hoisted.resolveAgentIdFromSessionKeyMock.mockClear(); + hoisted.resolveRunModelFallbacksOverrideMock.mockClear(); }); it("resolves model fallback options from run context", () => { - hoisted.resolveAgentIdFromSessionKeyMock.mockReturnValue("agent-id"); - hoisted.resolveAgentModelFallbacksOverrideMock.mockReturnValue(["fallback-model"]); + hoisted.resolveRunModelFallbacksOverrideMock.mockReturnValue(["fallback-model"]); const run = makeRun(); const resolved = resolveModelFallbackOptions(run); - expect(hoisted.resolveAgentIdFromSessionKeyMock).not.toHaveBeenCalled(); - expect(hoisted.resolveAgentModelFallbacksOverrideMock).toHaveBeenCalledWith( - run.config, - run.agentId, - ); + expect(hoisted.resolveRunModelFallbacksOverrideMock).toHaveBeenCalledWith({ + cfg: run.config, + agentId: run.agentId, + sessionKey: run.sessionKey, + }); expect(resolved).toEqual({ cfg: run.config, provider: run.provider, @@ -75,18 +67,17 @@ describe("agent-runner-utils", () => { }); }); - it("falls back to sessionKey agent id when run.agentId is missing", () => { - hoisted.resolveAgentIdFromSessionKeyMock.mockReturnValue("agent-from-session-key"); - hoisted.resolveAgentModelFallbacksOverrideMock.mockReturnValue(["fallback-model"]); + it("passes through missing agentId for helper-based fallback resolution", () => { + hoisted.resolveRunModelFallbacksOverrideMock.mockReturnValue(["fallback-model"]); const run = makeRun({ agentId: undefined }); const resolved = resolveModelFallbackOptions(run); - expect(hoisted.resolveAgentIdFromSessionKeyMock).toHaveBeenCalledWith(run.sessionKey); - expect(hoisted.resolveAgentModelFallbacksOverrideMock).toHaveBeenCalledWith( - run.config, - "agent-from-session-key", - ); + expect(hoisted.resolveRunModelFallbacksOverrideMock).toHaveBeenCalledWith({ + cfg: run.config, + agentId: undefined, + sessionKey: run.sessionKey, + }); expect(resolved.fallbacksOverride).toEqual(["fallback-model"]); }); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 3ec5c27566b1..ace68914e189 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -1,10 +1,9 @@ -import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js"; +import { resolveRunModelFallbacksOverride } from "../../agents/agent-scope.js"; import type { NormalizedUsage } from "../../agents/usage.js"; import { getChannelDock } from "../../channels/dock.js"; import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import { normalizeAnyChannelId, normalizeChannelId } from "../../channels/registry.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { resolveAgentIdFromSessionKey } from "../../config/sessions.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js"; import type { TemplateContext } from "../templating.js"; @@ -147,13 +146,16 @@ export const resolveEnforceFinalTag = (run: FollowupRun["run"], provider: string Boolean(run.enforceFinalTag || isReasoningTagProvider(provider)); export function resolveModelFallbackOptions(run: FollowupRun["run"]) { - const fallbackAgentId = run.agentId ?? resolveAgentIdFromSessionKey(run.sessionKey); return { cfg: run.config, provider: run.provider, model: run.model, agentDir: run.agentDir, - fallbacksOverride: resolveAgentModelFallbacksOverride(run.config, fallbackAgentId), + fallbacksOverride: resolveRunModelFallbacksOverride({ + cfg: run.config, + agentId: run.agentId, + sessionKey: run.sessionKey, + }), }; } diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 872fc8cebb7b..ba78b7abf21c 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -1,10 +1,10 @@ import crypto from "node:crypto"; -import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js"; +import { resolveRunModelFallbacksOverride } from "../../agents/agent-scope.js"; import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; -import { resolveAgentIdFromSessionKey, type SessionEntry } from "../../config/sessions.js"; +import type { SessionEntry } from "../../config/sessions.js"; import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; @@ -133,10 +133,11 @@ export function createFollowupRunner(params: { provider: queued.run.provider, model: queued.run.model, agentDir: queued.run.agentDir, - fallbacksOverride: resolveAgentModelFallbacksOverride( - queued.run.config, - queued.run.agentId ?? resolveAgentIdFromSessionKey(queued.run.sessionKey), - ), + fallbacksOverride: resolveRunModelFallbacksOverride({ + cfg: queued.run.config, + agentId: queued.run.agentId, + sessionKey: queued.run.sessionKey, + }), run: (provider, model) => { const authProfile = resolveRunAuthProfile(queued.run, provider); return runEmbeddedPiAgent({ From 146c92069bdcd92c3b666587c24f72e225fa3e18 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 04:34:59 +0000 Subject: [PATCH 380/408] fix: stabilize live docker test handling --- scripts/e2e/gateway-network-docker.sh | 29 ++++++++----- src/agents/models.profiles.live.test.ts | 41 ++++++++++++++++--- .../gateway-models.profiles.live.test.ts | 41 ++++++++++++++++--- 3 files changed, 90 insertions(+), 21 deletions(-) diff --git a/scripts/e2e/gateway-network-docker.sh b/scripts/e2e/gateway-network-docker.sh index 0aa0773a5de7..0749fc13f2d9 100644 --- a/scripts/e2e/gateway-network-docker.sh +++ b/scripts/e2e/gateway-network-docker.sh @@ -22,20 +22,23 @@ echo "Creating Docker network..." docker network create "$NET_NAME" >/dev/null echo "Starting gateway container..." - docker run --rm -d \ - --name "$GW_NAME" \ - --network "$NET_NAME" \ - -e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \ - -e "OPENCLAW_SKIP_CHANNELS=1" \ - -e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \ - -e "OPENCLAW_SKIP_CRON=1" \ - -e "OPENCLAW_SKIP_CANVAS_HOST=1" \ - "$IMAGE_NAME" \ - bash -lc "entry=dist/index.mjs; [ -f \"\$entry\" ] || entry=dist/index.js; node \"\$entry\" gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1" +docker run -d \ + --name "$GW_NAME" \ + --network "$NET_NAME" \ + -e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \ + -e "OPENCLAW_SKIP_CHANNELS=1" \ + -e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \ + -e "OPENCLAW_SKIP_CRON=1" \ + -e "OPENCLAW_SKIP_CANVAS_HOST=1" \ + "$IMAGE_NAME" \ + bash -lc "set -euo pipefail; entry=dist/index.mjs; [ -f \"\$entry\" ] || entry=dist/index.js; node \"\$entry\" config set gateway.controlUi.enabled false >/dev/null; node \"\$entry\" gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1" echo "Waiting for gateway to come up..." ready=0 for _ in $(seq 1 40); do + if [ "$(docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" != "true" ]; then + break + fi if docker exec "$GW_NAME" bash -lc "node --input-type=module -e ' import net from \"node:net\"; const socket = net.createConnection({ host: \"127.0.0.1\", port: $PORT }); @@ -65,7 +68,11 @@ done if [ "$ready" -ne 1 ]; then echo "Gateway failed to start" - docker exec "$GW_NAME" bash -lc "tail -n 80 /tmp/gateway-net-e2e.log" || true + if [ "$(docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" = "true" ]; then + docker exec "$GW_NAME" bash -lc "tail -n 80 /tmp/gateway-net-e2e.log" || true + else + docker logs "$GW_NAME" 2>&1 | tail -n 120 || true + fi exit 1 fi diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts index 2db27d076719..7def3441ab62 100644 --- a/src/agents/models.profiles.live.test.ts +++ b/src/agents/models.profiles.live.test.ts @@ -45,6 +45,23 @@ function logProgress(message: string): void { console.log(`[live] ${message}`); } +function formatFailurePreview( + failures: Array<{ model: string; error: string }>, + maxItems: number, +): string { + const limit = Math.max(1, maxItems); + const lines = failures.slice(0, limit).map((failure, index) => { + const normalized = failure.error.replace(/\s+/g, " ").trim(); + const clipped = normalized.length > 320 ? `${normalized.slice(0, 317)}...` : normalized; + return `${index + 1}. ${failure.model}: ${clipped}`; + }); + const remaining = failures.length - limit; + if (remaining > 0) { + lines.push(`... and ${remaining} more`); + } + return lines.join("\n"); +} + function isGoogleModelNotFoundError(err: unknown): boolean { const msg = String(err); if (!/not found/i.test(msg)) { @@ -95,6 +112,16 @@ function isModelTimeoutError(raw: string): boolean { return /model call timed out after \d+ms/i.test(raw); } +function isProviderUnavailableErrorMessage(raw: string): boolean { + const msg = raw.toLowerCase(); + return ( + msg.includes("no allowed providers are available") || + msg.includes("provider unavailable") || + msg.includes("upstream provider unavailable") || + msg.includes("upstream error from google") + ); +} + function toInt(value: string | undefined, fallback: number): number { const trimmed = value?.trim(); if (!trimmed) { @@ -592,6 +619,11 @@ describeLive("live models (profile keys)", () => { logProgress(`${progressLabel}: skip (timeout)`); break; } + if (allowNotFoundSkip && isProviderUnavailableErrorMessage(message)) { + skipped.push({ model: id, reason: message }); + logProgress(`${progressLabel}: skip (provider unavailable)`); + break; + } logProgress(`${progressLabel}: failed`); failures.push({ model: id, error: message }); break; @@ -600,11 +632,10 @@ describeLive("live models (profile keys)", () => { } if (failures.length > 0) { - const preview = failures - .slice(0, 10) - .map((f) => `- ${f.model}: ${f.error}`) - .join("\n"); - throw new Error(`live model failures (${failures.length}):\n${preview}`); + const preview = formatFailurePreview(failures, 20); + throw new Error( + `live model failures (${failures.length}, showing ${Math.min(failures.length, 20)}):\n${preview}`, + ); } void skipped; diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index f8cd415cfe02..3b2888da49d3 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -111,6 +111,23 @@ function logProgress(message: string): void { console.log(`[live] ${message}`); } +function formatFailurePreview( + failures: Array<{ model: string; error: string }>, + maxItems: number, +): string { + const limit = Math.max(1, maxItems); + const lines = failures.slice(0, limit).map((failure, index) => { + const normalized = failure.error.replace(/\s+/g, " ").trim(); + const clipped = normalized.length > 320 ? `${normalized.slice(0, 317)}...` : normalized; + return `${index + 1}. ${failure.model}: ${clipped}`; + }); + const remaining = failures.length - limit; + if (remaining > 0) { + lines.push(`... and ${remaining} more`); + } + return lines.join("\n"); +} + function assertNoReasoningTags(params: { text: string; model: string; @@ -179,6 +196,16 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean { return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in"); } +function isProviderUnavailableErrorMessage(raw: string): boolean { + const msg = raw.toLowerCase(); + return ( + msg.includes("no allowed providers are available") || + msg.includes("provider unavailable") || + msg.includes("upstream provider unavailable") || + msg.includes("upstream error from google") + ); +} + function isInstructionsRequiredError(error: string): boolean { return /instructions are required/i.test(error); } @@ -1013,6 +1040,11 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { logProgress(`${progressLabel}: skip (anthropic empty response)`); break; } + if (isProviderUnavailableErrorMessage(message)) { + skippedCount += 1; + logProgress(`${progressLabel}: skip (provider unavailable)`); + break; + } // OpenAI Codex refresh tokens can become single-use; skip instead of failing all live tests. if (model.provider === "openai-codex" && isRefreshTokenReused(message)) { logProgress(`${progressLabel}: skip (codex refresh token reused)`); @@ -1061,11 +1093,10 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { } if (failures.length > 0) { - const preview = failures - .slice(0, 20) - .map((f) => `- ${f.model}: ${f.error}`) - .join("\n"); - throw new Error(`gateway live model failures (${failures.length}):\n${preview}`); + const preview = formatFailurePreview(failures, 20); + throw new Error( + `gateway live model failures (${failures.length}, showing ${Math.min(failures.length, 20)}):\n${preview}`, + ); } if (skippedCount === total) { logProgress(`[${params.label}] skipped all models (missing profiles)`); From 38c4944d7660abb9fabca89cc1bcb3c164b89ea7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 04:39:07 +0000 Subject: [PATCH 381/408] docs(security): clarify trusted plugin boundary --- SECURITY.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index fea3cda8357c..eb42a3355720 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -51,6 +51,7 @@ These are frequently reported but are typically closed with no code change: - Prompt-injection-only chains without a boundary bypass (prompt injection is out of scope). - Operator-intended local features (for example TUI local `!` shell) presented as remote injection. - Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass. +- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it. - Reports that assume per-user multi-tenant authorization on a shared gateway host/config. - ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass. - Missing HSTS findings on default local/loopback deployments. @@ -93,6 +94,14 @@ OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boun - Implicit exec calls (no explicit host in the tool call) follow the same behavior. - This is expected in OpenClaw's one-user trusted-operator model. If you need isolation, enable sandbox mode (`non-main`/`all`) and keep strict tool policy. +## Trusted Plugin Concept (Core) + +Plugins/extensions are part of OpenClaw's trusted computing base for a gateway. + +- Installing or enabling a plugin grants it the same trust level as local code running on that gateway host. +- Plugin behavior such as reading env/files or running host commands is expected inside this trust boundary. +- Security reports must show a boundary bypass (for example unauthenticated plugin load, allowlist/policy bypass, or sandbox/path-safety bypass), not only malicious behavior from a trusted-installed plugin. + ## Out of Scope - Public Internet Exposure @@ -101,6 +110,7 @@ OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boun - Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass) - Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`) - Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary +- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior). - Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design) - Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses. - Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact From 09200b3c10b871194a4ce789c4c06a17f27a0297 Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Tue, 24 Feb 2026 21:36:04 -0700 Subject: [PATCH 382/408] security(nextcloud-talk): reject unsigned webhooks before body read --- .../src/monitor.auth-order.test.ts | 73 +++++++++++++++++++ extensions/nextcloud-talk/src/monitor.ts | 5 +- extensions/nextcloud-talk/src/types.ts | 1 + 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 extensions/nextcloud-talk/src/monitor.auth-order.test.ts diff --git a/extensions/nextcloud-talk/src/monitor.auth-order.test.ts b/extensions/nextcloud-talk/src/monitor.auth-order.test.ts new file mode 100644 index 000000000000..f2b4b65054d9 --- /dev/null +++ b/extensions/nextcloud-talk/src/monitor.auth-order.test.ts @@ -0,0 +1,73 @@ +import { type AddressInfo } from "node:net"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createNextcloudTalkWebhookServer } from "./monitor.js"; + +type WebhookHarness = { + webhookUrl: string; + stop: () => Promise; +}; + +const cleanupFns: Array<() => Promise> = []; + +afterEach(async () => { + while (cleanupFns.length > 0) { + const cleanup = cleanupFns.pop(); + if (cleanup) { + await cleanup(); + } + } +}); + +async function startWebhookServer(params: { + path: string; + maxBodyBytes: number; + readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise; +}): Promise { + const { server, start } = createNextcloudTalkWebhookServer({ + port: 0, + host: "127.0.0.1", + path: params.path, + secret: "nextcloud-secret", + maxBodyBytes: params.maxBodyBytes, + readBody: params.readBody, + onMessage: vi.fn(), + }); + await start(); + const address = server.address() as AddressInfo | null; + if (!address) { + throw new Error("missing server address"); + } + return { + webhookUrl: `http://127.0.0.1:${address.port}${params.path}`, + stop: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} + +describe("createNextcloudTalkWebhookServer auth order", () => { + it("rejects missing signature headers before reading request body", async () => { + const readBody = vi.fn(async () => { + throw new Error("should not be called for missing signature headers"); + }); + const harness = await startWebhookServer({ + path: "/nextcloud-auth-order", + maxBodyBytes: 128, + readBody, + }); + cleanupFns.push(harness.stop); + + const response = await fetch(harness.webhookUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: "{}", + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Missing signature headers" }); + expect(readBody).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index b7daac4d07c3..4b68a3c4d0b8 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -92,6 +92,7 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe opts.maxBodyBytes > 0 ? Math.floor(opts.maxBodyBytes) : DEFAULT_WEBHOOK_MAX_BODY_BYTES; + const readBody = opts.readBody ?? readNextcloudTalkWebhookBody; const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { if (req.url === HEALTH_PATH) { @@ -107,8 +108,6 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe } try { - const body = await readNextcloudTalkWebhookBody(req, maxBodyBytes); - const headers = extractNextcloudTalkHeaders( req.headers as Record, ); @@ -118,6 +117,8 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe return; } + const body = await readBody(req, maxBodyBytes); + const isValid = verifyNextcloudTalkSignature({ signature: headers.signature, random: headers.random, diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index ecdbe8437ae4..a9fe49be36da 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -169,6 +169,7 @@ export type NextcloudTalkWebhookServerOptions = { path: string; secret: string; maxBodyBytes?: number; + readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise; onMessage: (message: NextcloudTalkInboundMessage) => void | Promise; onError?: (error: Error) => void; abortSignal?: AbortSignal; From 0a58328217af94579e2c7d46069931d8ce99db97 Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Tue, 24 Feb 2026 21:35:31 -0700 Subject: [PATCH 383/408] security(nextcloud-talk): isolate group allowlist from pairing-store entries --- .../nextcloud-talk/src/inbound.authz.test.ts | 81 +++++++++++++++++++ extensions/nextcloud-talk/src/inbound.ts | 2 +- 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 extensions/nextcloud-talk/src/inbound.authz.test.ts diff --git a/extensions/nextcloud-talk/src/inbound.authz.test.ts b/extensions/nextcloud-talk/src/inbound.authz.test.ts new file mode 100644 index 000000000000..88a655ec4426 --- /dev/null +++ b/extensions/nextcloud-talk/src/inbound.authz.test.ts @@ -0,0 +1,81 @@ +import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; +import { handleNextcloudTalkInbound } from "./inbound.js"; +import { setNextcloudTalkRuntime } from "./runtime.js"; +import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js"; + +describe("nextcloud-talk inbound authz", () => { + it("does not treat DM pairing-store entries as group allowlist entries", async () => { + const readAllowFromStore = vi.fn(async () => ["attacker"]); + const buildMentionRegexes = vi.fn(() => [/@openclaw/i]); + + setNextcloudTalkRuntime({ + channel: { + pairing: { + readAllowFromStore, + }, + commands: { + shouldHandleTextCommands: () => false, + }, + text: { + hasControlCommand: () => false, + }, + mentions: { + buildMentionRegexes, + matchesMentionPatterns: () => false, + }, + }, + } as unknown as PluginRuntime); + + const message: NextcloudTalkInboundMessage = { + messageId: "m-1", + roomToken: "room-1", + roomName: "Room 1", + senderId: "attacker", + senderName: "Attacker", + text: "hello", + mediaType: "text/plain", + timestamp: Date.now(), + isGroupChat: true, + }; + + const account: ResolvedNextcloudTalkAccount = { + accountId: "default", + enabled: true, + baseUrl: "", + secret: "", + secretSource: "none", + config: { + dmPolicy: "pairing", + allowFrom: [], + groupPolicy: "allowlist", + groupAllowFrom: [], + }, + }; + + const config: CoreConfig = { + channels: { + "nextcloud-talk": { + dmPolicy: "pairing", + allowFrom: [], + groupPolicy: "allowlist", + groupAllowFrom: [], + }, + }, + }; + + await handleNextcloudTalkInbound({ + message, + account, + config, + runtime: { + log: vi.fn(), + error: vi.fn(), + } as unknown as RuntimeEnv, + }); + + expect(readAllowFromStore).toHaveBeenCalledWith("nextcloud-talk"); + expect(buildMentionRegexes).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index dcef6aa93822..526249aa9772 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -122,7 +122,7 @@ export async function handleNextcloudTalkInbound(params: { configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom; const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean); - const effectiveGroupAllowFrom = [...baseGroupAllowFrom, ...storeAllowList].filter(Boolean); + const effectiveGroupAllowFrom = [...baseGroupAllowFrom].filter(Boolean); const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg: config as OpenClawConfig, From d1bed505c5bdf45a257267b7347266f28974a475 Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Tue, 24 Feb 2026 21:31:05 -0700 Subject: [PATCH 384/408] security(irc): isolate group allowlist from DM pairing store --- extensions/irc/src/inbound.policy.test.ts | 34 +++++++++++++++++++++++ extensions/irc/src/inbound.ts | 25 +++++++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 extensions/irc/src/inbound.policy.test.ts diff --git a/extensions/irc/src/inbound.policy.test.ts b/extensions/irc/src/inbound.policy.test.ts new file mode 100644 index 000000000000..c5b6cdfac897 --- /dev/null +++ b/extensions/irc/src/inbound.policy.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { __testing } from "./inbound.js"; + +describe("irc inbound policy", () => { + it("keeps DM allowlist merged with pairing-store entries", () => { + const resolved = __testing.resolveIrcEffectiveAllowlists({ + configAllowFrom: ["owner"], + configGroupAllowFrom: [], + storeAllowList: ["paired-user"], + }); + + expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]); + }); + + it("does not grant group access from pairing-store when explicit groupAllowFrom exists", () => { + const resolved = __testing.resolveIrcEffectiveAllowlists({ + configAllowFrom: ["owner"], + configGroupAllowFrom: ["group-owner"], + storeAllowList: ["paired-user"], + }); + + expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]); + }); + + it("does not grant group access from pairing-store when groupAllowFrom is empty", () => { + const resolved = __testing.resolveIrcEffectiveAllowlists({ + configAllowFrom: ["owner"], + configGroupAllowFrom: [], + storeAllowList: ["paired-user"], + }); + + expect(resolved.effectiveGroupAllowFrom).toEqual([]); + }); +}); diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index 26d0aa85927a..efb0b781d4a3 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -31,6 +31,20 @@ const CHANNEL_ID = "irc" as const; const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +function resolveIrcEffectiveAllowlists(params: { + configAllowFrom: string[]; + configGroupAllowFrom: string[]; + storeAllowList: string[]; +}): { + effectiveAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; +} { + const effectiveAllowFrom = [...params.configAllowFrom, ...params.storeAllowList].filter(Boolean); + // Pairing-store entries are DM approvals and must not widen group sender authorization. + const effectiveGroupAllowFrom = [...params.configGroupAllowFrom].filter(Boolean); + return { effectiveAllowFrom, effectiveGroupAllowFrom }; +} + async function deliverIrcReply(params: { payload: OutboundReplyPayload; target: string; @@ -123,8 +137,11 @@ export async function handleIrcInbound(params: { const groupAllowFrom = directGroupAllowFrom.length > 0 ? directGroupAllowFrom : wildcardGroupAllowFrom; - const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean); - const effectiveGroupAllowFrom = [...configGroupAllowFrom, ...storeAllowList].filter(Boolean); + const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveIrcEffectiveAllowlists({ + configAllowFrom, + configGroupAllowFrom, + storeAllowList, + }); const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg: config as OpenClawConfig, @@ -344,3 +361,7 @@ export async function handleIrcInbound(params: { }, }); } + +export const __testing = { + resolveIrcEffectiveAllowlists, +}; From 107bda27c9d857832d79ce57259104969f549329 Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Tue, 24 Feb 2026 21:29:20 -0700 Subject: [PATCH 385/408] security(msteams): isolate group allowlist from pairing-store entries --- .../message-handler.authz.test.ts | 96 +++++++++++++++++++ .../src/monitor-handler/message-handler.ts | 12 +-- 2 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 extensions/msteams/src/monitor-handler/message-handler.authz.test.ts diff --git a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts new file mode 100644 index 000000000000..124599147a86 --- /dev/null +++ b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts @@ -0,0 +1,96 @@ +import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js"; +import { setMSTeamsRuntime } from "../runtime.js"; +import { createMSTeamsMessageHandler } from "./message-handler.js"; + +describe("msteams monitor handler authz", () => { + it("does not treat DM pairing-store entries as group allowlist entries", async () => { + const readAllowFromStore = vi.fn(async () => ["attacker-aad"]); + setMSTeamsRuntime({ + logging: { shouldLogVerbose: () => false }, + channel: { + debounce: { + resolveInboundDebounceMs: () => 0, + createInboundDebouncer: (params: { + onFlush: (entries: T[]) => Promise; + }): { enqueue: (entry: T) => Promise } => ({ + enqueue: async (entry: T) => { + await params.onFlush([entry]); + }, + }), + }, + pairing: { + readAllowFromStore, + upsertPairingRequest: vi.fn(async () => null), + }, + text: { + hasControlCommand: () => false, + }, + }, + } as unknown as PluginRuntime); + + const conversationStore = { + upsert: vi.fn(async () => undefined), + }; + + const deps: MSTeamsMessageHandlerDeps = { + cfg: { + channels: { + msteams: { + dmPolicy: "pairing", + allowFrom: [], + groupPolicy: "allowlist", + groupAllowFrom: [], + }, + }, + } as OpenClawConfig, + runtime: { error: vi.fn() } as unknown as RuntimeEnv, + appId: "test-app", + adapter: {} as MSTeamsMessageHandlerDeps["adapter"], + tokenProvider: { + getAccessToken: vi.fn(async () => "token"), + }, + textLimit: 4000, + mediaMaxBytes: 1024 * 1024, + conversationStore: + conversationStore as unknown as MSTeamsMessageHandlerDeps["conversationStore"], + pollStore: { + recordVote: vi.fn(async () => null), + } as unknown as MSTeamsMessageHandlerDeps["pollStore"], + log: { + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + } as unknown as MSTeamsMessageHandlerDeps["log"], + }; + + const handler = createMSTeamsMessageHandler(deps); + await handler({ + activity: { + id: "msg-1", + type: "message", + text: "", + from: { + id: "attacker-id", + aadObjectId: "attacker-aad", + name: "Attacker", + }, + recipient: { + id: "bot-id", + name: "Bot", + }, + conversation: { + id: "19:group@thread.tacv2", + conversationType: "groupChat", + }, + channelData: {}, + attachments: [], + }, + sendActivity: vi.fn(async () => undefined), + } as unknown as Parameters[0]); + + expect(readAllowFromStore).toHaveBeenCalledWith("msteams"); + expect(conversationStore.upsert).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 085efeeb0a88..a87f704a3405 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -135,7 +135,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { // Check DM policy for direct messages. const dmAllowFrom = msteamsCfg?.allowFrom ?? []; - const effectiveDmAllowFrom = [...dmAllowFrom.map((v) => String(v)), ...storedAllowFrom]; + const configuredDmAllowFrom = dmAllowFrom.map((v) => String(v)); + const effectiveDmAllowFrom = [...configuredDmAllowFrom, ...storedAllowFrom]; if (isDirectMessage && msteamsCfg) { const allowFrom = dmAllowFrom; @@ -189,9 +190,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { (msteamsCfg.allowFrom && msteamsCfg.allowFrom.length > 0 ? msteamsCfg.allowFrom : [])) : []; const effectiveGroupAllowFrom = - !isDirectMessage && msteamsCfg - ? [...groupAllowFrom.map((v) => String(v)), ...storedAllowFrom] - : []; + !isDirectMessage && msteamsCfg ? groupAllowFrom.map((v) => String(v)) : []; const teamId = activity.channelData?.team?.id; const teamName = activity.channelData?.team?.name; const channelName = activity.channelData?.channel?.name; @@ -248,9 +247,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { } } + const commandDmAllowFrom = isDirectMessage ? effectiveDmAllowFrom : configuredDmAllowFrom; const ownerAllowedForCommands = isMSTeamsGroupAllowed({ groupPolicy: "allowlist", - allowFrom: effectiveDmAllowFrom, + allowFrom: commandDmAllowFrom, senderId, senderName, allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), @@ -266,7 +266,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ - { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, ], allowTextCommands: true, From 19d2a8998b90a18b39df97b67b85eb7fec4c1dc5 Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Tue, 24 Feb 2026 21:22:36 -0700 Subject: [PATCH 386/408] security(line): cap unsigned webhook body read budget --- src/line/webhook-node.test.ts | 22 ++++++++++++++++++++++ src/line/webhook-node.ts | 17 ++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/line/webhook-node.test.ts b/src/line/webhook-node.test.ts index c3840ec92df5..0414f63d243a 100644 --- a/src/line/webhook-node.test.ts +++ b/src/line/webhook-node.test.ts @@ -104,6 +104,28 @@ describe("createLineNodeWebhookHandler", () => { expect(bot.handleWebhook).not.toHaveBeenCalled(); }); + it("uses a tight body-read limit for unsigned POST requests", async () => { + const bot = { handleWebhook: vi.fn(async () => {}) }; + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const readBody = vi.fn(async (_req: IncomingMessage, maxBytes: number) => { + expect(maxBytes).toBe(4096); + return JSON.stringify({ events: [{ type: "message" }] }); + }); + const handler = createLineNodeWebhookHandler({ + channelSecret: "secret", + bot, + runtime, + readBody, + }); + + const { res } = createRes(); + await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res); + + expect(res.statusCode).toBe(400); + expect(readBody).toHaveBeenCalledTimes(1); + expect(bot.handleWebhook).not.toHaveBeenCalled(); + }); + it("rejects invalid signature", async () => { const rawBody = JSON.stringify({ events: [{ type: "message" }] }); const { bot, handler } = createPostWebhookTestHarness(rawBody); diff --git a/src/line/webhook-node.ts b/src/line/webhook-node.ts index 493f00e186ba..da914c90a065 100644 --- a/src/line/webhook-node.ts +++ b/src/line/webhook-node.ts @@ -11,6 +11,7 @@ import { validateLineSignature } from "./signature.js"; import { isLineWebhookVerificationRequest, parseLineWebhookBody } from "./webhook-utils.js"; const LINE_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; +const LINE_WEBHOOK_UNSIGNED_MAX_BODY_BYTES = 4 * 1024; const LINE_WEBHOOK_BODY_TIMEOUT_MS = 30_000; export async function readLineWebhookRequestBody( @@ -54,8 +55,18 @@ export function createLineNodeWebhookHandler(params: { } try { - const rawBody = await readBody(req, maxBodyBytes); - const signature = req.headers["x-line-signature"]; + const signatureHeader = req.headers["x-line-signature"]; + const signature = + typeof signatureHeader === "string" + ? signatureHeader + : Array.isArray(signatureHeader) + ? signatureHeader[0] + : undefined; + const hasSignature = typeof signature === "string" && signature.trim().length > 0; + const bodyLimit = hasSignature + ? maxBodyBytes + : Math.min(maxBodyBytes, LINE_WEBHOOK_UNSIGNED_MAX_BODY_BYTES); + const rawBody = await readBody(req, bodyLimit); // Parse once; we may need it for verification requests and for event processing. const body = parseLineWebhookBody(rawBody); @@ -63,7 +74,7 @@ export function createLineNodeWebhookHandler(params: { // LINE webhook verification sends POST {"events":[]} without a // signature header. Return 200 so the LINE Developers Console // "Verify" button succeeds. - if (!signature || typeof signature !== "string") { + if (!hasSignature) { if (isLineWebhookVerificationRequest(body)) { logVerbose("line: webhook verification request (empty events, no signature) - 200 OK"); res.statusCode = 200; From f7de41ca20d7de34f0aa0e410ac58c8b344a981e Mon Sep 17 00:00:00 2001 From: Sid Date: Wed, 25 Feb 2026 12:52:08 +0800 Subject: [PATCH 387/408] fix(followup): fall back to dispatcher when same-channel origin routing fails (#26109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(followup): fall back to dispatcher when same-channel origin routing fails When routeReply fails for an originating channel that matches the session's messageProvider, the onBlockReply callback was created by that same channel's handler and can safely deliver the reply. Previously the payload was silently dropped on any routeReply failure, causing Feishu DM replies to never reach the user. Cross-channel fallback (origin ≠ provider) still drops the payload to preserve origin isolation. Closes #25767 Co-authored-by: Cursor * fix: allow same-channel followup fallback routing (#26109) (thanks @Sid-Qin) --------- Co-authored-by: Cursor Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/auto-reply/reply/followup-runner.test.ts | 42 +++++++++++++++++++- src/auto-reply/reply/followup-runner.ts | 17 +++++++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0ec48ff23ed..fb1208004ab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) +- Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. ## 2026.2.24 diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 7627c79a5990..da5d55fa9dd1 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -8,6 +8,7 @@ import { createMockTypingController } from "./test-helpers.js"; const runEmbeddedPiAgentMock = vi.fn(); const routeReplyMock = vi.fn(); +const isRoutableChannelMock = vi.fn(); vi.mock( "../../agents/model-fallback.js", @@ -22,15 +23,30 @@ vi.mock("./route-reply.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + isRoutableChannel: (...args: unknown[]) => isRoutableChannelMock(...args), routeReply: (...args: unknown[]) => routeReplyMock(...args), }; }); import { createFollowupRunner } from "./followup-runner.js"; +const ROUTABLE_TEST_CHANNELS = new Set([ + "telegram", + "slack", + "discord", + "signal", + "imessage", + "whatsapp", + "feishu", +]); + beforeEach(() => { routeReplyMock.mockReset(); routeReplyMock.mockResolvedValue({ ok: true }); + isRoutableChannelMock.mockReset(); + isRoutableChannelMock.mockImplementation((ch: string | undefined) => + Boolean(ch?.trim() && ROUTABLE_TEST_CHANNELS.has(ch.trim().toLowerCase())), + ); }); const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun => @@ -336,7 +352,7 @@ describe("createFollowupRunner messaging tool dedupe", () => { expect(store[sessionKey]?.outputTokens).toBe(50); }); - it("does not fall back to dispatcher when explicit origin routing fails", async () => { + it("does not fall back to dispatcher when cross-channel origin routing fails", async () => { const onBlockReply = vi.fn(async () => {}); runEmbeddedPiAgentMock.mockResolvedValueOnce({ payloads: [{ text: "hello world!" }], @@ -359,6 +375,30 @@ describe("createFollowupRunner messaging tool dedupe", () => { expect(onBlockReply).not.toHaveBeenCalled(); }); + it("falls back to dispatcher when same-channel origin routing fails", async () => { + const onBlockReply = vi.fn(async () => {}); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "hello world!" }], + meta: {}, + }); + routeReplyMock.mockResolvedValueOnce({ + ok: false, + error: "outbound adapter unavailable", + }); + + const runner = createMessagingDedupeRunner(onBlockReply); + + await runner({ + ...baseQueuedRun(" Feishu "), + originatingChannel: "FEISHU", + originatingTo: "ou_abc123", + } as FollowupRun); + + expect(routeReplyMock).toHaveBeenCalled(); + expect(onBlockReply).toHaveBeenCalledTimes(1); + expect(onBlockReply).toHaveBeenCalledWith(expect.objectContaining({ text: "hello world!" })); + }); + it("routes followups with originating account/thread metadata", async () => { const onBlockReply = vi.fn(async () => {}); runEmbeddedPiAgentMock.mockResolvedValueOnce({ diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index ba78b7abf21c..0c91d543d916 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -103,10 +103,23 @@ export function createFollowupRunner(params: { cfg: queued.run.config, }); if (!result.ok) { - // Keep origin isolation strict: do not fall back to the current - // dispatcher when explicit origin routing failed. const errorMsg = result.error ?? "unknown error"; logVerbose(`followup queue: route-reply failed: ${errorMsg}`); + // Fall back to the caller-provided dispatcher only when the + // originating channel matches the session's message provider. + // In that case onBlockReply was created by the same channel's + // handler and delivers to the correct destination. For true + // cross-channel routing (origin !== provider), falling back + // would send to the wrong channel, so we drop the payload. + const provider = resolveOriginMessageProvider({ + provider: queued.run.messageProvider, + }); + const origin = resolveOriginMessageProvider({ + originatingChannel, + }); + if (opts?.onBlockReply && origin && origin === provider) { + await opts.onBlockReply(payload); + } } } else if (opts?.onBlockReply) { await opts.onBlockReply(payload); From 156f13aa64cdbb11056250842c9a8d0bbb22b68a Mon Sep 17 00:00:00 2001 From: Sid Date: Wed, 25 Feb 2026 12:53:26 +0800 Subject: [PATCH 388/408] fix(agents): continue fallback loop for unrecognized provider errors (#26106) * fix(agents): continue fallback loop for unrecognized provider errors When a provider returns an error that coerceToFailoverError cannot classify (e.g., custom error messages without standard HTTP status codes), the fallback loop threw immediately instead of trying the next candidate. This caused fallback to stop after 2 models even when 17 were configured. Only rethrow unrecognized errors when they occur on the last candidate. For intermediate candidates, record the error as an attempt and continue to the next model. Closes #25926 Co-authored-by: Cursor * test: cover unknown-error fallback telemetry and land #26106 (thanks @Sid-Qin) --------- Co-authored-by: Cursor Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/agents/model-fallback.test.ts | 46 +++++++++++++++++++++++++++++-- src/agents/model-fallback.ts | 13 ++++++--- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb1208004ab1..47dcf207ad81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) - Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. +- Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin. ## 2026.2.24 diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 903c292ec1ef..16592cdb4560 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -178,18 +178,60 @@ describe("runWithModelFallback", () => { expect(run).toHaveBeenCalledWith("openai-codex", "gpt-5.3-codex"); }); - it("does not fall back on non-auth errors", async () => { + it("falls back on unrecognized errors when candidates remain", async () => { const cfg = makeCfg(); const run = vi.fn().mockRejectedValueOnce(new Error("bad request")).mockResolvedValueOnce("ok"); + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + run, + }); + expect(result.result).toBe("ok"); + expect(run).toHaveBeenCalledTimes(2); + expect(result.attempts).toHaveLength(1); + expect(result.attempts[0].error).toBe("bad request"); + expect(result.attempts[0].reason).toBe("unknown"); + }); + + it("passes original unknown errors to onError during fallback", async () => { + const cfg = makeCfg(); + const unknownError = new Error("provider misbehaved"); + const run = vi.fn().mockRejectedValueOnce(unknownError).mockResolvedValueOnce("ok"); + const onError = vi.fn(); + + await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + run, + onError, + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]?.[0]).toMatchObject({ + provider: "openai", + model: "gpt-4.1-mini", + attempt: 1, + total: 2, + }); + expect(onError.mock.calls[0]?.[0]?.error).toBe(unknownError); + }); + + it("throws unrecognized error on last candidate", async () => { + const cfg = makeCfg(); + const run = vi.fn().mockRejectedValueOnce(new Error("something weird")); + await expect( runWithModelFallback({ cfg, provider: "openai", model: "gpt-4.1-mini", run, + fallbacksOverride: [], }), - ).rejects.toThrow("bad request"); + ).rejects.toThrow("something weird"); expect(run).toHaveBeenCalledTimes(1); }); diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index b75eb8de4bf6..e59d9e9357c7 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -402,24 +402,29 @@ export async function runWithModelFallback(params: { provider: candidate.provider, model: candidate.model, }) ?? err; - if (!isFailoverError(normalized)) { + + // Even unrecognized errors should not abort the fallback loop when + // there are remaining candidates. Only abort/context-overflow errors + // (handled above) are truly non-retryable. + const isKnownFailover = isFailoverError(normalized); + if (!isKnownFailover && i === candidates.length - 1) { throw err; } - lastError = normalized; + lastError = isKnownFailover ? normalized : err; const described = describeFailoverError(normalized); attempts.push({ provider: candidate.provider, model: candidate.model, error: described.message, - reason: described.reason, + reason: described.reason ?? "unknown", status: described.status, code: described.code, }); await params.onError?.({ provider: candidate.provider, model: candidate.model, - error: normalized, + error: isKnownFailover ? normalized : err, attempt: i + 1, total: candidates.length, }); From 2e84017f23266a10200c99da7c9de86ef5a694fc Mon Sep 17 00:00:00 2001 From: Sid Date: Wed, 25 Feb 2026 12:54:51 +0800 Subject: [PATCH 389/408] fix(markdown): require paired || delimiters for spoiler detection (#26105) * fix(markdown): require paired || delimiters for spoiler detection An unpaired || (odd count across all inline tokens) would open a spoiler that never closes, causing closeRemainingStyles to extend it to the end of the text. This made all content after an unpaired || appear as hidden/spoiler in Telegram. Pre-count || delimiters across the entire inline token group and skip spoiler injection entirely when the count is less than 2 or odd. This prevents single | characters and unpaired || from triggering spoiler formatting. Closes #26068 Co-authored-by: Cursor * fix: preserve valid spoiler pairs with trailing unmatched delimiters (#26105) (thanks @Sid-Qin) --------- Co-authored-by: Cursor Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/markdown/ir.ts | 28 ++++++++++++++++++++++++++++ src/telegram/format.test.ts | 18 ++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47dcf207ad81..627ee478a6d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) - Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. - Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin. +- Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin. ## 2026.2.24 diff --git a/src/markdown/ir.ts b/src/markdown/ir.ts index 17203c6972dc..bab451bc3e63 100644 --- a/src/markdown/ir.ts +++ b/src/markdown/ir.ts @@ -144,8 +144,31 @@ function applySpoilerTokens(tokens: MarkdownToken[]): void { } function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] { + let totalDelims = 0; + for (const token of tokens) { + if (token.type !== "text") { + continue; + } + const content = token.content ?? ""; + let i = 0; + while (i < content.length) { + const next = content.indexOf("||", i); + if (next === -1) { + break; + } + totalDelims += 1; + i = next + 2; + } + } + + if (totalDelims < 2) { + return tokens; + } + const usableDelims = totalDelims - (totalDelims % 2); + const result: MarkdownToken[] = []; const state = { spoilerOpen: false }; + let consumedDelims = 0; for (const token of tokens) { if (token.type !== "text") { @@ -168,9 +191,14 @@ function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] { } break; } + if (consumedDelims >= usableDelims) { + result.push(createTextToken(token, content.slice(index))); + break; + } if (next > index) { result.push(createTextToken(token, content.slice(index, next))); } + consumedDelims += 1; state.spoilerOpen = !state.spoilerOpen; result.push({ type: state.spoilerOpen ? "spoiler_open" : "spoiler_close", diff --git a/src/telegram/format.test.ts b/src/telegram/format.test.ts index 0e27bc074e32..ac4163b96f06 100644 --- a/src/telegram/format.test.ts +++ b/src/telegram/format.test.ts @@ -94,4 +94,22 @@ describe("markdownToTelegramHtml", () => { const res = markdownToTelegramHtml("||**secret** text||"); expect(res).toBe("secret text"); }); + + it("does not treat single pipe as spoiler", () => { + const res = markdownToTelegramHtml("( ̄_ ̄|) face"); + expect(res).not.toContain("tg-spoiler"); + expect(res).toContain("|"); + }); + + it("does not treat unpaired || as spoiler", () => { + const res = markdownToTelegramHtml("before || after"); + expect(res).not.toContain("tg-spoiler"); + expect(res).toContain("||"); + }); + + it("keeps valid spoiler pairs when a trailing || is unmatched", () => { + const res = markdownToTelegramHtml("||secret|| trailing ||"); + expect(res).toContain("secret"); + expect(res).toContain("trailing ||"); + }); }); From 24a60799be5a83192cf8aed69a975e77d32cb3c0 Mon Sep 17 00:00:00 2001 From: David Rudduck <47308254+davidrudduck@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:56:19 +1000 Subject: [PATCH 390/408] fix(hooks): include guildId and channelName in message_received metadata (#26115) * fix(hooks): include guildId and channelName in message_received metadata The message_received hook (both plugin and internal) already exposes sender identity fields (senderId, senderName, senderUsername, senderE164) but omits the guild/channel context. Plugins that track per-channel activity receive NULL values for channel identification. Add guildId (ctx.GroupSpace) and channelName (ctx.GroupChannel) to the metadata block in both the plugin hook and internal hook dispatch paths. These properties are already populated by channel providers (e.g. Discord sets GroupSpace to the guild ID and GroupChannel to #channel-name) and used elsewhere in the codebase (channels/conversation-label.ts). * test: cover guild/channel hook metadata propagation (#26115) (thanks @davidrudduck) --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/auto-reply/reply/dispatch-from-config.test.ts | 10 ++++++++++ src/auto-reply/reply/dispatch-from-config.ts | 4 ++++ 3 files changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 627ee478a6d5..f5146870374c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. - Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin. - Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin. +- Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck. ## 2026.2.24 diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index bd1715bf5117..aac29ce49df0 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -407,6 +407,8 @@ describe("dispatchReplyFromConfig", () => { SenderUsername: "alice", SenderE164: "+15555550123", AccountId: "acc-1", + GroupSpace: "guild-123", + GroupChannel: "alerts", }); const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; @@ -425,6 +427,8 @@ describe("dispatchReplyFromConfig", () => { senderName: "Alice", senderUsername: "alice", senderE164: "+15555550123", + guildId: "guild-123", + channelName: "alerts", }), }), expect.objectContaining({ @@ -445,6 +449,8 @@ describe("dispatchReplyFromConfig", () => { SessionKey: "agent:main:main", CommandBody: "/help", MessageSid: "msg-42", + GroupSpace: "guild-456", + GroupChannel: "ops-room", }); const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; @@ -459,6 +465,10 @@ describe("dispatchReplyFromConfig", () => { content: "/help", channelId: "telegram", messageId: "msg-42", + metadata: expect.objectContaining({ + guildId: "guild-456", + channelName: "ops-room", + }), }), ); expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 881b1afe6fed..234ab1e5a0ed 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -187,6 +187,8 @@ export async function dispatchReplyFromConfig(params: { senderName: ctx.SenderName, senderUsername: ctx.SenderUsername, senderE164: ctx.SenderE164, + guildId: ctx.GroupSpace, + channelName: ctx.GroupChannel, }, }, { @@ -220,6 +222,8 @@ export async function dispatchReplyFromConfig(params: { senderName: ctx.SenderName, senderUsername: ctx.SenderUsername, senderE164: ctx.SenderE164, + guildId: ctx.GroupSpace, + channelName: ctx.GroupChannel, }, }), ).catch((err) => { From c1964e73a8fbad4dbb3a2c8f41e83cb01d9a95cf Mon Sep 17 00:00:00 2001 From: bmendonca3 Date: Tue, 24 Feb 2026 21:57:41 -0700 Subject: [PATCH 391/408] fix(discord): gate component command authorization for guild interactions (#26119) * Discord: gate component command authorization * test: cover allowlisted guild component authorization path (#26119) (thanks @bmendonca3) --------- Co-authored-by: Brian Mendonca Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/discord/monitor/agent-components.ts | 64 ++++++++++++++++++++++++- src/discord/monitor/monitor.test.ts | 64 +++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5146870374c..395b96e33c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin. - Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin. - Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck. +- Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3. ## 2026.2.24 diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index c4d317803111..e39adf58165a 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -23,6 +23,7 @@ import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto- import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -727,6 +728,57 @@ function formatModalSubmissionText( return lines.join("\n"); } +function resolveComponentCommandAuthorized(params: { + ctx: AgentComponentContext; + interactionCtx: ComponentInteractionContext; + channelConfig: ReturnType; + guildInfo: ReturnType; + allowNameMatching: boolean; +}): boolean { + const { ctx, interactionCtx, channelConfig, guildInfo } = params; + if (interactionCtx.isDirectMessage) { + return true; + } + + const ownerAllowList = normalizeDiscordAllowList(ctx.allowFrom, ["discord:", "user:", "pk:"]); + const ownerOk = ownerAllowList + ? resolveDiscordAllowListMatch({ + allowList: ownerAllowList, + candidate: { + id: interactionCtx.user.id, + name: interactionCtx.user.username, + tag: formatDiscordUserTag(interactionCtx.user), + }, + allowNameMatching: params.allowNameMatching, + }).allowed + : false; + + const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({ + channelConfig, + guildInfo, + memberRoleIds: interactionCtx.memberRoleIds, + sender: { + id: interactionCtx.user.id, + name: interactionCtx.user.username, + tag: formatDiscordUserTag(interactionCtx.user), + }, + allowNameMatching: params.allowNameMatching, + }); + const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false; + const authorizers = useAccessGroups + ? [ + { configured: ownerAllowList != null, allowed: ownerOk }, + { configured: hasAccessRestrictions, allowed: memberAllowed }, + ] + : [{ configured: hasAccessRestrictions, allowed: memberAllowed }]; + + return resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers, + modeWhenAccessGroupsOff: "configured", + }); +} + async function dispatchDiscordComponentEvent(params: { ctx: AgentComponentContext; interaction: AgentComponentInteraction; @@ -780,12 +832,20 @@ async function dispatchDiscordComponentEvent(params: { parentSlug: channelCtx.parentSlug, scope: channelCtx.isThread ? "thread" : "channel", }); + const allowNameMatching = isDangerousNameMatchingEnabled(ctx.discordConfig); const groupSystemPrompt = channelConfig?.systemPrompt?.trim() || undefined; const ownerAllowFrom = resolveDiscordOwnerAllowFrom({ channelConfig, guildInfo, sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag }, - allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig), + allowNameMatching, + }); + const commandAuthorized = resolveComponentCommandAuthorized({ + ctx, + interactionCtx, + channelConfig, + guildInfo, + allowNameMatching, }); const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId }); const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg); @@ -830,7 +890,7 @@ async function dispatchDiscordComponentEvent(params: { Provider: "discord" as const, Surface: "discord" as const, WasMentioned: true, - CommandAuthorized: true, + CommandAuthorized: commandAuthorized, CommandSource: "text" as const, MessageSid: interaction.rawData.id, Timestamp: timestamp, diff --git a/src/discord/monitor/monitor.test.ts b/src/discord/monitor/monitor.test.ts index 18fdce2e7868..afa9bbd93a74 100644 --- a/src/discord/monitor/monitor.test.ts +++ b/src/discord/monitor/monitor.test.ts @@ -391,6 +391,70 @@ describe("discord component interactions", () => { expect(resolveDiscordModalEntry({ id: "mdl_1" })).toBeNull(); }); + it("does not mark guild modal events as command-authorized for non-allowlisted users", async () => { + registerDiscordComponentEntries({ + entries: [], + modals: [createModalEntry()], + }); + + const modal = createDiscordComponentModal( + createComponentContext({ + cfg: { + commands: { useAccessGroups: true }, + channels: { discord: { replyToMode: "first" } }, + } as OpenClawConfig, + allowFrom: ["owner-1"], + }), + ); + const { interaction, acknowledge } = createModalInteraction({ + rawData: { + channel_id: "guild-channel", + guild_id: "guild-1", + id: "interaction-guild-1", + member: { roles: [] }, + } as unknown as ModalInteraction["rawData"], + guild: { id: "guild-1", name: "Test Guild" } as unknown as ModalInteraction["guild"], + }); + + await modal.run(interaction, { mid: "mdl_1" } as ComponentData); + + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(dispatchReplyMock).toHaveBeenCalledTimes(1); + expect(lastDispatchCtx?.CommandAuthorized).toBe(false); + }); + + it("marks guild modal events as command-authorized for allowlisted users", async () => { + registerDiscordComponentEntries({ + entries: [], + modals: [createModalEntry()], + }); + + const modal = createDiscordComponentModal( + createComponentContext({ + cfg: { + commands: { useAccessGroups: true }, + channels: { discord: { replyToMode: "first" } }, + } as OpenClawConfig, + allowFrom: ["123456789"], + }), + ); + const { interaction, acknowledge } = createModalInteraction({ + rawData: { + channel_id: "guild-channel", + guild_id: "guild-1", + id: "interaction-guild-2", + member: { roles: [] }, + } as unknown as ModalInteraction["rawData"], + guild: { id: "guild-1", name: "Test Guild" } as unknown as ModalInteraction["guild"], + }); + + await modal.run(interaction, { mid: "mdl_1" } as ComponentData); + + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(dispatchReplyMock).toHaveBeenCalledTimes(1); + expect(lastDispatchCtx?.CommandAuthorized).toBe(true); + }); + it("keeps reusable modal entries active after submission", async () => { const { acknowledge } = await runModalSubmission({ reusable: true }); From b564b72dc9f9ecbe4ac262cba0f614f72e2cd06f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 04:52:37 +0000 Subject: [PATCH 392/408] docs(changelog): add missing security PR entries (#26118 #26116 #26112 #26111 #26095) --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 395b96e33c24..a5b905033e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. +- Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. +- Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3. +- Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3. +- Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3. - Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) - Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. - Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin. From 6e97470515807db4d61294feaef537d1f452ad62 Mon Sep 17 00:00:00 2001 From: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Date: Wed, 25 Feb 2026 00:59:38 -0400 Subject: [PATCH 393/408] fix(brave-search): clarify ui_lang and search_lang format requirements (#25130) * fix(brave-search): swap ui_lang and search_lang formats (#23826) * fix(web-search): normalize Brave ui_lang/search_lang params --------- Co-authored-by: Peter Steinberger --- src/agents/tools/web-search.test.ts | 23 ++++++++ src/agents/tools/web-search.ts | 90 +++++++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 95e8e878bc7b..8c4960569ea0 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -7,6 +7,7 @@ const { resolvePerplexityBaseUrl, isDirectPerplexityBaseUrl, resolvePerplexityRequestModel, + normalizeBraveLanguageParams, normalizeFreshness, freshnessToPerplexityRecency, resolveGrokApiKey, @@ -93,6 +94,28 @@ describe("web_search perplexity model normalization", () => { }); }); +describe("web_search brave language param normalization", () => { + it("normalizes and auto-corrects swapped Brave language params", () => { + expect(normalizeBraveLanguageParams({ search_lang: "tr-TR", ui_lang: "tr" })).toEqual({ + search_lang: "tr", + ui_lang: "tr-TR", + }); + expect(normalizeBraveLanguageParams({ search_lang: "EN", ui_lang: "en-us" })).toEqual({ + search_lang: "en", + ui_lang: "en-US", + }); + }); + + it("flags invalid Brave language formats", () => { + expect(normalizeBraveLanguageParams({ search_lang: "en-US" })).toEqual({ + invalidField: "search_lang", + }); + expect(normalizeBraveLanguageParams({ ui_lang: "en" })).toEqual({ + invalidField: "ui_lang", + }); + }); +}); + describe("web_search freshness normalization", () => { it("accepts Brave shortcut values", () => { expect(normalizeFreshness("pd")).toBe("pd"); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 0c299f6be0fe..321f41e4d11a 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -43,6 +43,8 @@ const KIMI_WEB_SEARCH_TOOL = { const SEARCH_CACHE = new Map>>(); const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]); const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/; +const BRAVE_SEARCH_LANG_CODE = /^[a-z]{2}$/i; +const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i; const TRUSTED_NETWORK_SSRF_POLICY = { dangerouslyAllowPrivateNetwork: true } as const; const WebSearchSchema = Type.Object({ @@ -62,12 +64,14 @@ const WebSearchSchema = Type.Object({ ), search_lang: Type.Optional( Type.String({ - description: "ISO language code for search results (e.g., 'de', 'en', 'fr').", + description: + "Short ISO language code for search results (e.g., 'de', 'en', 'fr', 'tr'). Must be a 2-letter code, NOT a locale.", }), ), ui_lang: Type.Optional( Type.String({ - description: "ISO language code for UI elements.", + description: + "Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.", }), ), freshness: Type.Optional( @@ -705,6 +709,62 @@ function resolveSearchCount(value: unknown, fallback: number): number { return clamped; } +function normalizeBraveSearchLang(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed || !BRAVE_SEARCH_LANG_CODE.test(trimmed)) { + return undefined; + } + return trimmed.toLowerCase(); +} + +function normalizeBraveUiLang(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const match = trimmed.match(BRAVE_UI_LANG_LOCALE); + if (!match) { + return undefined; + } + const [, language, region] = match; + return `${language.toLowerCase()}-${region.toUpperCase()}`; +} + +function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): { + search_lang?: string; + ui_lang?: string; + invalidField?: "search_lang" | "ui_lang"; +} { + const rawSearchLang = params.search_lang?.trim() || undefined; + const rawUiLang = params.ui_lang?.trim() || undefined; + let searchLangCandidate = rawSearchLang; + let uiLangCandidate = rawUiLang; + + // Recover common LLM mix-up: locale in search_lang + short code in ui_lang. + if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) { + searchLangCandidate = rawUiLang; + uiLangCandidate = rawSearchLang; + } + + const search_lang = normalizeBraveSearchLang(searchLangCandidate); + if (searchLangCandidate && !search_lang) { + return { invalidField: "search_lang" }; + } + + const ui_lang = normalizeBraveUiLang(uiLangCandidate); + if (uiLangCandidate && !ui_lang) { + return { invalidField: "ui_lang" }; + } + + return { search_lang, ui_lang }; +} + function normalizeFreshness(value: string | undefined): string | undefined { if (!value) { return undefined; @@ -1289,8 +1349,29 @@ export function createWebSearchTool(options?: { const count = readNumberParam(params, "count", { integer: true }) ?? search?.maxResults ?? undefined; const country = readStringParam(params, "country"); - const search_lang = readStringParam(params, "search_lang"); - const ui_lang = readStringParam(params, "ui_lang"); + const rawSearchLang = readStringParam(params, "search_lang"); + const rawUiLang = readStringParam(params, "ui_lang"); + const normalizedBraveLanguageParams = + provider === "brave" + ? normalizeBraveLanguageParams({ search_lang: rawSearchLang, ui_lang: rawUiLang }) + : { search_lang: rawSearchLang, ui_lang: rawUiLang }; + if (normalizedBraveLanguageParams.invalidField === "search_lang") { + return jsonResult({ + error: "invalid_search_lang", + message: + "search_lang must be a 2-letter ISO language code like 'en' (not a locale like 'en-US').", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (normalizedBraveLanguageParams.invalidField === "ui_lang") { + return jsonResult({ + error: "invalid_ui_lang", + message: "ui_lang must be a language-region locale like 'en-US'.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const search_lang = normalizedBraveLanguageParams.search_lang; + const ui_lang = normalizedBraveLanguageParams.ui_lang; const rawFreshness = readStringParam(params, "freshness"); if (rawFreshness && provider !== "brave" && provider !== "perplexity") { return jsonResult({ @@ -1342,6 +1423,7 @@ export const __testing = { resolvePerplexityBaseUrl, isDirectPerplexityBaseUrl, resolvePerplexityRequestModel, + normalizeBraveLanguageParams, normalizeFreshness, freshnessToPerplexityRecency, resolveGrokApiKey, From 52d933b3a958f524a4e807078c7fe58870d8d05c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 05:03:20 +0000 Subject: [PATCH 394/408] refactor: replace bot.molt identifiers with ai.openclaw --- CHANGELOG.md | 1 + apps/ios/Sources/Device/NetworkStatusService.swift | 2 +- apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift | 2 +- apps/ios/Sources/Screen/ScreenRecordService.swift | 2 +- apps/ios/Sources/Voice/TalkModeManager.swift | 2 +- apps/ios/Tests/KeychainStoreTests.swift | 2 +- apps/ios/fastlane/Appfile | 2 +- docs/gateway/remote-gateway-readme.md | 10 +++++----- docs/help/faq.md | 2 +- docs/install/nix.md | 2 +- docs/install/uninstall.md | 8 ++++---- docs/install/updating.md | 2 +- docs/platforms/index.md | 2 +- docs/platforms/mac/bundled-gateway.md | 6 +++--- docs/platforms/mac/child-process.md | 10 +++++----- docs/platforms/mac/dev-setup.md | 2 +- docs/platforms/mac/logging.md | 8 ++++---- docs/platforms/mac/permissions.md | 4 ++-- docs/platforms/mac/release.md | 4 ++-- docs/platforms/mac/voice-overlay.md | 4 ++-- docs/platforms/mac/webchat.md | 2 +- docs/platforms/macos.md | 10 +++++----- scripts/restart-mac.sh | 2 +- src/cli/daemon-cli.coverage.test.ts | 2 +- src/commands/status.test.ts | 4 ++-- 25 files changed, 49 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5b905033e8f..1d341f29f63b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus. +- Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures. ### Fixes diff --git a/apps/ios/Sources/Device/NetworkStatusService.swift b/apps/ios/Sources/Device/NetworkStatusService.swift index 7d92d1cc1ca1..bc27eb19791f 100644 --- a/apps/ios/Sources/Device/NetworkStatusService.swift +++ b/apps/ios/Sources/Device/NetworkStatusService.swift @@ -6,7 +6,7 @@ final class NetworkStatusService: @unchecked Sendable { func currentStatus(timeoutMs: Int = 1500) async -> OpenClawNetworkStatusPayload { await withCheckedContinuation { cont in let monitor = NWPathMonitor() - let queue = DispatchQueue(label: "bot.molt.ios.network-status") + let queue = DispatchQueue(label: "ai.openclaw.ios.network-status") let state = NetworkStatusState() monitor.pathUpdateHandler = { path in diff --git a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift index ce1ba4bf2cb6..04bb220d5f36 100644 --- a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift +++ b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift @@ -104,7 +104,7 @@ final class GatewayDiscoveryModel { } self.browsers[domain] = browser - browser.start(queue: DispatchQueue(label: "bot.molt.ios.gateway-discovery.\(domain)")) + browser.start(queue: DispatchQueue(label: "ai.openclaw.ios.gateway-discovery.\(domain)")) } } diff --git a/apps/ios/Sources/Screen/ScreenRecordService.swift b/apps/ios/Sources/Screen/ScreenRecordService.swift index 11052f235432..c353d86f22d7 100644 --- a/apps/ios/Sources/Screen/ScreenRecordService.swift +++ b/apps/ios/Sources/Screen/ScreenRecordService.swift @@ -55,7 +55,7 @@ final class ScreenRecordService: @unchecked Sendable { outPath: outPath) let state = CaptureState() - let recordQueue = DispatchQueue(label: "bot.molt.screenrecord") + let recordQueue = DispatchQueue(label: "ai.openclaw.screenrecord") try await self.startCapture(state: state, config: config, recordQueue: recordQueue) try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000) diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index d0ae9bc5cb2b..0f8a7e6461b6 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -95,7 +95,7 @@ final class TalkModeManager: NSObject { private var incrementalSpeechPrefetch: IncrementalSpeechPrefetchState? private var incrementalSpeechPrefetchMonitorTask: Task? - private let logger = Logger(subsystem: "bot.molt", category: "TalkMode") + private let logger = Logger(subsystem: "ai.openclaw", category: "TalkMode") init(allowSimulatorCapture: Bool = false) { self.allowSimulatorCapture = allowSimulatorCapture diff --git a/apps/ios/Tests/KeychainStoreTests.swift b/apps/ios/Tests/KeychainStoreTests.swift index 827be250ed7f..e56f4aa35b5a 100644 --- a/apps/ios/Tests/KeychainStoreTests.swift +++ b/apps/ios/Tests/KeychainStoreTests.swift @@ -4,7 +4,7 @@ import Testing @Suite struct KeychainStoreTests { @Test func saveLoadUpdateDeleteRoundTrip() { - let service = "bot.molt.tests.\(UUID().uuidString)" + let service = "ai.openclaw.tests.\(UUID().uuidString)" let account = "value" #expect(KeychainStore.delete(service: service, account: account)) diff --git a/apps/ios/fastlane/Appfile b/apps/ios/fastlane/Appfile index adaa3fc29fb6..8dbb75a8c262 100644 --- a/apps/ios/fastlane/Appfile +++ b/apps/ios/fastlane/Appfile @@ -1,4 +1,4 @@ -app_identifier("bot.molt.ios") +app_identifier("ai.openclaw.ios") # Auth is expected via App Store Connect API key. # Provide either: diff --git a/docs/gateway/remote-gateway-readme.md b/docs/gateway/remote-gateway-readme.md index 27fbfb6d2a9a..cb069629070e 100644 --- a/docs/gateway/remote-gateway-readme.md +++ b/docs/gateway/remote-gateway-readme.md @@ -84,7 +84,7 @@ To have the SSH tunnel start automatically when you log in, create a Launch Agen ### Create the PLIST file -Save this as `~/Library/LaunchAgents/bot.molt.ssh-tunnel.plist`: +Save this as `~/Library/LaunchAgents/ai.openclaw.ssh-tunnel.plist`: ```xml @@ -92,7 +92,7 @@ Save this as `~/Library/LaunchAgents/bot.molt.ssh-tunnel.plist`: Label - bot.molt.ssh-tunnel + ai.openclaw.ssh-tunnel ProgramArguments /usr/bin/ssh @@ -110,7 +110,7 @@ Save this as `~/Library/LaunchAgents/bot.molt.ssh-tunnel.plist`: ### Load the Launch Agent ```bash -launchctl bootstrap gui/$UID ~/Library/LaunchAgents/bot.molt.ssh-tunnel.plist +launchctl bootstrap gui/$UID ~/Library/LaunchAgents/ai.openclaw.ssh-tunnel.plist ``` The tunnel will now: @@ -135,13 +135,13 @@ lsof -i :18789 **Restart the tunnel:** ```bash -launchctl kickstart -k gui/$UID/bot.molt.ssh-tunnel +launchctl kickstart -k gui/$UID/ai.openclaw.ssh-tunnel ``` **Stop the tunnel:** ```bash -launchctl bootout gui/$UID/bot.molt.ssh-tunnel +launchctl bootout gui/$UID/ai.openclaw.ssh-tunnel ``` --- diff --git a/docs/help/faq.md b/docs/help/faq.md index a16dba1a7dc1..b5c5fa8f24af 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -2475,7 +2475,7 @@ Quick setup (recommended): - Set a unique `gateway.port` in each profile config (or pass `--port` for manual runs). - Install a per-profile service: `openclaw --profile gateway install`. -Profiles also suffix service names (`bot.molt.`; legacy `com.openclaw.*`, `openclaw-gateway-.service`, `OpenClaw Gateway ()`). +Profiles also suffix service names (`ai.openclaw.`; legacy `com.openclaw.*`, `openclaw-gateway-.service`, `OpenClaw Gateway ()`). Full guide: [Multiple gateways](/gateway/multiple-gateways). ### What does invalid handshake code 1008 mean diff --git a/docs/install/nix.md b/docs/install/nix.md index a17e46589a7b..784ca24707aa 100644 --- a/docs/install/nix.md +++ b/docs/install/nix.md @@ -58,7 +58,7 @@ On macOS, the GUI app does not automatically inherit shell env vars. You can also enable Nix mode via defaults: ```bash -defaults write bot.molt.mac openclaw.nixMode -bool true +defaults write ai.openclaw.mac openclaw.nixMode -bool true ``` ### Config + state paths diff --git a/docs/install/uninstall.md b/docs/install/uninstall.md index f5543ce1c45b..09c5587579b4 100644 --- a/docs/install/uninstall.md +++ b/docs/install/uninstall.md @@ -81,14 +81,14 @@ Use this if the gateway service keeps running but `openclaw` is missing. ### macOS (launchd) -Default label is `bot.molt.gateway` (or `bot.molt.`; legacy `com.openclaw.*` may still exist): +Default label is `ai.openclaw.gateway` (or `ai.openclaw.`; legacy `com.openclaw.*` may still exist): ```bash -launchctl bootout gui/$UID/bot.molt.gateway -rm -f ~/Library/LaunchAgents/bot.molt.gateway.plist +launchctl bootout gui/$UID/ai.openclaw.gateway +rm -f ~/Library/LaunchAgents/ai.openclaw.gateway.plist ``` -If you used a profile, replace the label and plist name with `bot.molt.`. Remove any legacy `com.openclaw.*` plists if present. +If you used a profile, replace the label and plist name with `ai.openclaw.`. Remove any legacy `com.openclaw.*` plists if present. ### Linux (systemd user unit) diff --git a/docs/install/updating.md b/docs/install/updating.md index 6606a933b7dc..f94c26007765 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -196,7 +196,7 @@ openclaw logs --follow If you’re supervised: -- macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/bot.molt.gateway` (use `bot.molt.`; legacy `com.openclaw.*` still works) +- macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/ai.openclaw.gateway` (use `ai.openclaw.`; legacy `com.openclaw.*` still works) - Linux systemd user service: `systemctl --user restart openclaw-gateway[-].service` - Windows (WSL2): `systemctl --user restart openclaw-gateway[-].service` - `launchctl`/`systemctl` only work if the service is installed; otherwise run `openclaw gateway install`. diff --git a/docs/platforms/index.md b/docs/platforms/index.md index 0f37c275cd3c..ec2663aefe40 100644 --- a/docs/platforms/index.md +++ b/docs/platforms/index.md @@ -49,5 +49,5 @@ Use one of these (all supported): The service target depends on OS: -- macOS: LaunchAgent (`bot.molt.gateway` or `bot.molt.`; legacy `com.openclaw.*`) +- macOS: LaunchAgent (`ai.openclaw.gateway` or `ai.openclaw.`; legacy `com.openclaw.*`) - Linux/WSL2: systemd user service (`openclaw-gateway[-].service`) diff --git a/docs/platforms/mac/bundled-gateway.md b/docs/platforms/mac/bundled-gateway.md index 54064656dca3..6cb878015fb4 100644 --- a/docs/platforms/mac/bundled-gateway.md +++ b/docs/platforms/mac/bundled-gateway.md @@ -28,12 +28,12 @@ The macOS app’s **Install CLI** button runs the same flow via npm/pnpm (bun no Label: -- `bot.molt.gateway` (or `bot.molt.`; legacy `com.openclaw.*` may remain) +- `ai.openclaw.gateway` (or `ai.openclaw.`; legacy `com.openclaw.*` may remain) Plist location (per‑user): -- `~/Library/LaunchAgents/bot.molt.gateway.plist` - (or `~/Library/LaunchAgents/bot.molt..plist`) +- `~/Library/LaunchAgents/ai.openclaw.gateway.plist` + (or `~/Library/LaunchAgents/ai.openclaw..plist`) Manager: diff --git a/docs/platforms/mac/child-process.md b/docs/platforms/mac/child-process.md index e009a58257c1..b65ca5f0d9d4 100644 --- a/docs/platforms/mac/child-process.md +++ b/docs/platforms/mac/child-process.md @@ -18,8 +18,8 @@ If you need tighter coupling to the UI, run the Gateway manually in a terminal. ## Default behavior (launchd) -- The app installs a per‑user LaunchAgent labeled `bot.molt.gateway` - (or `bot.molt.` when using `--profile`/`OPENCLAW_PROFILE`; legacy `com.openclaw.*` is supported). +- The app installs a per‑user LaunchAgent labeled `ai.openclaw.gateway` + (or `ai.openclaw.` when using `--profile`/`OPENCLAW_PROFILE`; legacy `com.openclaw.*` is supported). - When Local mode is enabled, the app ensures the LaunchAgent is loaded and starts the Gateway if needed. - Logs are written to the launchd gateway log path (visible in Debug Settings). @@ -27,11 +27,11 @@ If you need tighter coupling to the UI, run the Gateway manually in a terminal. Common commands: ```bash -launchctl kickstart -k gui/$UID/bot.molt.gateway -launchctl bootout gui/$UID/bot.molt.gateway +launchctl kickstart -k gui/$UID/ai.openclaw.gateway +launchctl bootout gui/$UID/ai.openclaw.gateway ``` -Replace the label with `bot.molt.` when running a named profile. +Replace the label with `ai.openclaw.` when running a named profile. ## Unsigned dev builds diff --git a/docs/platforms/mac/dev-setup.md b/docs/platforms/mac/dev-setup.md index 8aff51348862..e50a850086ac 100644 --- a/docs/platforms/mac/dev-setup.md +++ b/docs/platforms/mac/dev-setup.md @@ -84,7 +84,7 @@ If the app crashes when you try to allow **Speech Recognition** or **Microphone* 1. Reset the TCC permissions: ```bash - tccutil reset All bot.molt.mac.debug + tccutil reset All ai.openclaw.mac.debug ``` 2. If that fails, change the `BUNDLE_ID` temporarily in [`scripts/package-mac-app.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/package-mac-app.sh) to force a "clean slate" from macOS. diff --git a/docs/platforms/mac/logging.md b/docs/platforms/mac/logging.md index c1abf717cc95..5e1af460e3c7 100644 --- a/docs/platforms/mac/logging.md +++ b/docs/platforms/mac/logging.md @@ -26,12 +26,12 @@ Notes: Unified logging redacts most payloads unless a subsystem opts into `privacy -off`. Per Peter's write-up on macOS [logging privacy shenanigans](https://steipete.me/posts/2025/logging-privacy-shenanigans) (2025) this is controlled by a plist in `/Library/Preferences/Logging/Subsystems/` keyed by the subsystem name. Only new log entries pick up the flag, so enable it before reproducing an issue. -## Enable for OpenClaw (`bot.molt`) +## Enable for OpenClaw (`ai.openclaw`) - Write the plist to a temp file first, then install it atomically as root: ```bash -cat <<'EOF' >/tmp/bot.molt.plist +cat <<'EOF' >/tmp/ai.openclaw.plist @@ -44,7 +44,7 @@ cat <<'EOF' >/tmp/bot.molt.plist EOF -sudo install -m 644 -o root -g wheel /tmp/bot.molt.plist /Library/Preferences/Logging/Subsystems/bot.molt.plist +sudo install -m 644 -o root -g wheel /tmp/ai.openclaw.plist /Library/Preferences/Logging/Subsystems/ai.openclaw.plist ``` - No reboot is required; logd notices the file quickly, but only new log lines will include private payloads. @@ -52,6 +52,6 @@ sudo install -m 644 -o root -g wheel /tmp/bot.molt.plist /Library/Preferences/Lo ## Disable after debugging -- Remove the override: `sudo rm /Library/Preferences/Logging/Subsystems/bot.molt.plist`. +- Remove the override: `sudo rm /Library/Preferences/Logging/Subsystems/ai.openclaw.plist`. - Optionally run `sudo log config --reload` to force logd to drop the override immediately. - Remember this surface can include phone numbers and message bodies; keep the plist in place only while you actively need the extra detail. diff --git a/docs/platforms/mac/permissions.md b/docs/platforms/mac/permissions.md index 12f75eb9f511..e749ecf9d771 100644 --- a/docs/platforms/mac/permissions.md +++ b/docs/platforms/mac/permissions.md @@ -35,8 +35,8 @@ grants, and prompts can disappear entirely until the stale entries are cleared. Example resets (replace bundle ID as needed): ```bash -sudo tccutil reset Accessibility bot.molt.mac -sudo tccutil reset ScreenCapture bot.molt.mac +sudo tccutil reset Accessibility ai.openclaw.mac +sudo tccutil reset ScreenCapture ai.openclaw.mac sudo tccutil reset AppleEvents ``` diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index db673765c042..978e79ff4806 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -33,7 +33,7 @@ Notes: ```bash # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. -BUNDLE_ID=bot.molt.mac \ +BUNDLE_ID=ai.openclaw.mac \ APP_VERSION=2026.2.25 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ @@ -51,7 +51,7 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.25.dmg # xcrun notarytool store-credentials "openclaw-notary" \ # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ -BUNDLE_ID=bot.molt.mac \ +BUNDLE_ID=ai.openclaw.mac \ APP_VERSION=2026.2.25 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ diff --git a/docs/platforms/mac/voice-overlay.md b/docs/platforms/mac/voice-overlay.md index 9c42601b1865..86f02d9ed244 100644 --- a/docs/platforms/mac/voice-overlay.md +++ b/docs/platforms/mac/voice-overlay.md @@ -37,7 +37,7 @@ Audience: macOS app contributors. Goal: keep the voice overlay predictable when - Push-to-talk: no delay; wake-word: optional delay for auto-send. - Apply a short cooldown to the wake runtime after push-to-talk finishes so wake-word doesn’t immediately retrigger. 5. **Logging** - - Coordinator emits `.info` logs in subsystem `bot.molt`, categories `voicewake.overlay` and `voicewake.chime`. + - Coordinator emits `.info` logs in subsystem `ai.openclaw`, categories `voicewake.overlay` and `voicewake.chime`. - Key events: `session_started`, `adopted_by_push_to_talk`, `partial`, `finalized`, `send`, `dismiss`, `cancel`, `cooldown`. ## Debugging checklist @@ -45,7 +45,7 @@ Audience: macOS app contributors. Goal: keep the voice overlay predictable when - Stream logs while reproducing a sticky overlay: ```bash - sudo log stream --predicate 'subsystem == "bot.molt" AND category CONTAINS "voicewake"' --level info --style compact + sudo log stream --predicate 'subsystem == "ai.openclaw" AND category CONTAINS "voicewake"' --level info --style compact ``` - Verify only one active session token; stale callbacks should be dropped by the coordinator. diff --git a/docs/platforms/mac/webchat.md b/docs/platforms/mac/webchat.md index ea6791ff50e8..11b500a8596d 100644 --- a/docs/platforms/mac/webchat.md +++ b/docs/platforms/mac/webchat.md @@ -24,7 +24,7 @@ agent (with a session switcher for other sessions). dist/OpenClaw.app/Contents/MacOS/OpenClaw --webchat ``` -- Logs: `./scripts/clawlog.sh` (subsystem `bot.molt`, category `WebChatSwiftUI`). +- Logs: `./scripts/clawlog.sh` (subsystem `ai.openclaw`, category `WebChatSwiftUI`). ## How it’s wired diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index a9327970261a..04c61df266af 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -34,15 +34,15 @@ capabilities to the agent as a node. ## Launchd control -The app manages a per‑user LaunchAgent labeled `bot.molt.gateway` -(or `bot.molt.` when using `--profile`/`OPENCLAW_PROFILE`; legacy `com.openclaw.*` still unloads). +The app manages a per‑user LaunchAgent labeled `ai.openclaw.gateway` +(or `ai.openclaw.` when using `--profile`/`OPENCLAW_PROFILE`; legacy `com.openclaw.*` still unloads). ```bash -launchctl kickstart -k gui/$UID/bot.molt.gateway -launchctl bootout gui/$UID/bot.molt.gateway +launchctl kickstart -k gui/$UID/ai.openclaw.gateway +launchctl bootout gui/$UID/ai.openclaw.gateway ``` -Replace the label with `bot.molt.` when running a named profile. +Replace the label with `ai.openclaw.` when running a named profile. If the LaunchAgent isn’t installed, enable it from the app or run `openclaw gateway install`. diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh index 0db3fad39b0e..ba1aab336b61 100755 --- a/scripts/restart-mac.sh +++ b/scripts/restart-mac.sh @@ -265,5 +265,5 @@ else fi if [ "$NO_SIGN" -eq 1 ] && [ "$ATTACH_ONLY" -ne 1 ]; then - run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/bot.molt.gateway.plist' | head -n 40 || true" + run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/ai.openclaw.gateway.plist' | head -n 40 || true" fi diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index 2813d486be23..0bffcd4c32d0 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -138,7 +138,7 @@ describe("daemon-cli coverage", () => { OPENCLAW_CONFIG_PATH: "/tmp/openclaw-daemon-state/openclaw.json", OPENCLAW_GATEWAY_PORT: "19001", }, - sourcePath: "/tmp/bot.molt.gateway.plist", + sourcePath: "/tmp/ai.openclaw.gateway.plist", }); await runDaemonCommand(["daemon", "status", "--json"]); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index e628d79aa7da..f4243b08abc3 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -297,7 +297,7 @@ vi.mock("../daemon/service.js", () => ({ readRuntime: async () => ({ status: "running", pid: 1234 }), readCommand: async () => ({ programArguments: ["node", "dist/entry.js", "gateway"], - sourcePath: "/tmp/Library/LaunchAgents/bot.molt.gateway.plist", + sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.gateway.plist", }), }), })); @@ -310,7 +310,7 @@ vi.mock("../daemon/node-service.js", () => ({ readRuntime: async () => ({ status: "running", pid: 4321 }), readCommand: async () => ({ programArguments: ["node", "dist/entry.js", "node-host"], - sourcePath: "/tmp/Library/LaunchAgents/bot.molt.node.plist", + sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.node.plist", }), }), })); From 8f5f599a343d274b11c7d63e873d80eef1abcbeb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 05:10:06 +0000 Subject: [PATCH 395/408] docs(security): note narrow filesystem roots for tool access --- docs/gateway/security/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index c9c3f4051e4a..3824d1d283e1 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -837,6 +837,7 @@ Additional hardening options: - `tools.exec.applyPatch.workspaceOnly: true` (default): ensures `apply_patch` cannot write/delete outside the workspace directory even when sandboxing is off. Set to `false` only if you intentionally want `apply_patch` to touch files outside the workspace. - `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths and native prompt image auto-load paths to the workspace directory (useful if you allow absolute paths today and want a single guardrail). +- Keep filesystem roots narrow: avoid broad roots like your home directory for agent workspaces/sandbox workspaces. Broad roots can expose sensitive local files (for example state/config under `~/.openclaw`) to filesystem tools. ### 5) Secure baseline (copy/paste) From 177386ed7318d5a8c756c536c1cb9791737435fb Mon Sep 17 00:00:00 2001 From: byungsker <72309817+lbo728@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:36:27 +0900 Subject: [PATCH 396/408] fix(tui): resolve wrong provider prefix when session has model without modelProvider (#25874) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: f0953a72845fb3f9e8745cb6ab476cea7a5cd98b Co-authored-by: lbo728 <72309817+lbo728@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 21 ++ src/agents/model-selection.test.ts | 80 ++++++ src/agents/model-selection.ts | 36 +++ src/commands/agent/session-store.ts | 12 +- src/config/sessions/sessions.test.ts | 37 +++ src/config/sessions/store.ts | 8 +- src/config/sessions/types.ts | 74 +++++- src/cron/isolated-agent/run.ts | 12 +- src/gateway/server-methods/sessions.ts | 1 + ...sessions.gateway-server-sessions-a.test.ts | 6 +- src/gateway/session-utils.test.ts | 247 ++++++++++++++++++ src/gateway/session-utils.ts | 38 ++- 12 files changed, 559 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d341f29f63b..cfd0c5514837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,27 @@ Docs: https://docs.openclaw.ai - Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting. - Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. Thanks @tdjackey for reporting. - Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. Thanks @tdjackey for reporting. +- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. +- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis. +- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis. +- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. +- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. +- Gateway/Sessions: preserve `modelProvider` on `sessions.reset` and avoid incorrect provider prefixes for legacy session models. (#25874) Thanks @lbo728. +- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. +- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. +- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr. +- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18. +- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi. +- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr. +- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728. +- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber. +- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. +- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. +- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach. +- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. +- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd. +- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. +- CLI/Memory search: accept `--query ` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky. - Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey. ## 2026.2.23 diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 78c214657732..8a80768c0dbb 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { resetLogger, setLoggerOverride } from "../logging/logger.js"; import { buildAllowedModelSet, + inferUniqueProviderFromConfiguredModels, parseModelRef, buildModelAliasIndex, modelKey, @@ -134,6 +135,85 @@ describe("model-selection", () => { }); }); + describe("inferUniqueProviderFromConfiguredModels", () => { + it("infers provider when configured model match is unique", () => { + const cfg = { + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + expect( + inferUniqueProviderFromConfiguredModels({ + cfg, + model: "claude-sonnet-4-6", + }), + ).toBe("anthropic"); + }); + + it("returns undefined when configured matches are ambiguous", () => { + const cfg = { + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-6": {}, + "minimax/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + expect( + inferUniqueProviderFromConfiguredModels({ + cfg, + model: "claude-sonnet-4-6", + }), + ).toBeUndefined(); + }); + + it("returns undefined for provider-prefixed model ids", () => { + const cfg = { + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + expect( + inferUniqueProviderFromConfiguredModels({ + cfg, + model: "anthropic/claude-sonnet-4-6", + }), + ).toBeUndefined(); + }); + + it("infers provider for slash-containing model id when allowlist match is unique", () => { + const cfg = { + agents: { + defaults: { + models: { + "vercel-ai-gateway/anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + expect( + inferUniqueProviderFromConfiguredModels({ + cfg, + model: "anthropic/claude-sonnet-4-6", + }), + ).toBe("vercel-ai-gateway"); + }); + }); + describe("buildModelAliasIndex", () => { it("should build alias index from config", () => { const cfg: Partial = { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index d37609af368a..ac45200039f3 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -171,6 +171,42 @@ export function parseModelRef(raw: string, defaultProvider: string): ModelRef | return normalizeModelRef(providerRaw, model); } +export function inferUniqueProviderFromConfiguredModels(params: { + cfg: OpenClawConfig; + model: string; +}): string | undefined { + const model = params.model.trim(); + if (!model) { + return undefined; + } + const configuredModels = params.cfg.agents?.defaults?.models; + if (!configuredModels) { + return undefined; + } + const normalized = model.toLowerCase(); + const providers = new Set(); + for (const key of Object.keys(configuredModels)) { + const ref = key.trim(); + if (!ref || !ref.includes("/")) { + continue; + } + const parsed = parseModelRef(ref, DEFAULT_PROVIDER); + if (!parsed) { + continue; + } + if (parsed.model === model || parsed.model.toLowerCase() === normalized) { + providers.add(parsed.provider); + if (providers.size > 1) { + return undefined; + } + } + } + if (providers.size !== 1) { + return undefined; + } + return providers.values().next().value; +} + export function normalizeModelSelection(value: unknown): string | undefined { if (typeof value === "string") { const trimmed = value.trim(); diff --git a/src/commands/agent/session-store.ts b/src/commands/agent/session-store.ts index 638a1c8eade8..21574090c12a 100644 --- a/src/commands/agent/session-store.ts +++ b/src/commands/agent/session-store.ts @@ -4,7 +4,11 @@ import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { isCliProvider } from "../../agents/model-selection.js"; import { deriveSessionTotalTokens, hasNonzeroUsage } from "../../agents/usage.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"; +import { + setSessionRuntimeModel, + type SessionEntry, + updateSessionStore, +} from "../../config/sessions.js"; type RunResult = Awaited< ReturnType<(typeof import("../../agents/pi-embedded.js"))["runEmbeddedPiAgent"]> @@ -58,10 +62,12 @@ export async function updateSessionStoreAfterAgentRun(params: { ...entry, sessionId, updatedAt: Date.now(), - modelProvider: providerUsed, - model: modelUsed, contextTokens, }; + setSessionRuntimeModel(next, { + provider: providerUsed, + model: modelUsed, + }); if (isCliProvider(providerUsed, cfg)) { const cliSessionId = result.meta.agentMeta?.sessionId?.trim(); if (cliSessionId) { diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 1bcbac5711c7..10ac5a13b453 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -6,6 +6,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from import { clearSessionStoreCacheForTest, loadSessionStore, + mergeSessionEntry, resolveAndPersistSessionFile, updateSessionStore, } from "../sessions.js"; @@ -215,6 +216,42 @@ describe("session store lock (Promise chain mutex)", () => { const store = loadSessionStore(storePath); expect(store[key]?.modelOverride).toBe("recovered"); }); + + it("clears stale runtime provider when model is patched without provider", () => { + const merged = mergeSessionEntry( + { + sessionId: "sess-runtime", + updatedAt: 100, + modelProvider: "anthropic", + model: "claude-opus-4-6", + }, + { + model: "gpt-5.2", + }, + ); + expect(merged.model).toBe("gpt-5.2"); + expect(merged.modelProvider).toBeUndefined(); + }); + + it("normalizes orphan modelProvider fields at store write boundary", async () => { + const key = "agent:main:orphan-provider"; + const { storePath } = await makeTmpStore({ + [key]: { + sessionId: "sess-orphan", + updatedAt: 100, + modelProvider: "anthropic", + }, + }); + + await updateSessionStore(storePath, async (store) => { + const entry = store[key]; + entry.updatedAt = Date.now(); + }); + + const store = loadSessionStore(storePath); + expect(store[key]?.modelProvider).toBeUndefined(); + expect(store[key]?.model).toBeUndefined(); + }); }); describe("appendAssistantMessageToSessionTranscript", () => { diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 210ebc99963a..d721cf4ad3ed 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -22,7 +22,11 @@ import { loadConfig } from "../config.js"; import type { SessionMaintenanceConfig, SessionMaintenanceMode } from "../types.base.js"; import { enforceSessionDiskBudget, type SessionDiskBudgetSweepResult } from "./disk-budget.js"; import { deriveSessionMetaPatch } from "./metadata.js"; -import { mergeSessionEntry, type SessionEntry } from "./types.js"; +import { + mergeSessionEntry, + normalizeSessionRuntimeModelFields, + type SessionEntry, +} from "./types.js"; const log = createSubsystemLogger("sessions/store"); @@ -157,7 +161,7 @@ function normalizeSessionStore(store: Record): void { if (!entry) { continue; } - const normalized = normalizeSessionEntryDelivery(entry); + const normalized = normalizeSessionEntryDelivery(normalizeSessionRuntimeModelFields(entry)); if (normalized !== entry) { store[key] = normalized; } diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 25091cd065ea..e00772677429 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -114,6 +114,65 @@ export type SessionEntry = { systemPromptReport?: SessionSystemPromptReport; }; +function normalizeRuntimeField(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function normalizeSessionRuntimeModelFields(entry: SessionEntry): SessionEntry { + const normalizedModel = normalizeRuntimeField(entry.model); + const normalizedProvider = normalizeRuntimeField(entry.modelProvider); + let next = entry; + + if (!normalizedModel) { + if (entry.model !== undefined || entry.modelProvider !== undefined) { + next = { ...next }; + delete next.model; + delete next.modelProvider; + } + return next; + } + + if (entry.model !== normalizedModel) { + if (next === entry) { + next = { ...next }; + } + next.model = normalizedModel; + } + + if (!normalizedProvider) { + if (entry.modelProvider !== undefined) { + if (next === entry) { + next = { ...next }; + } + delete next.modelProvider; + } + return next; + } + + if (entry.modelProvider !== normalizedProvider) { + if (next === entry) { + next = { ...next }; + } + next.modelProvider = normalizedProvider; + } + return next; +} + +export function setSessionRuntimeModel( + entry: SessionEntry, + runtime: { provider: string; model: string }, +): boolean { + const provider = runtime.provider.trim(); + const model = runtime.model.trim(); + if (!provider || !model) { + return false; + } + entry.modelProvider = provider; + entry.model = model; + return true; +} + export function mergeSessionEntry( existing: SessionEntry | undefined, patch: Partial, @@ -121,9 +180,20 @@ export function mergeSessionEntry( const sessionId = patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID(); const updatedAt = Math.max(existing?.updatedAt ?? 0, patch.updatedAt ?? 0, Date.now()); if (!existing) { - return { ...patch, sessionId, updatedAt }; + return normalizeSessionRuntimeModelFields({ ...patch, sessionId, updatedAt }); + } + const next = { ...existing, ...patch, sessionId, updatedAt }; + + // Guard against stale provider carry-over when callers patch runtime model + // without also patching runtime provider. + if (Object.hasOwn(patch, "model") && !Object.hasOwn(patch, "modelProvider")) { + const patchedModel = normalizeRuntimeField(patch.model); + const existingModel = normalizeRuntimeField(existing.model); + if (patchedModel && patchedModel !== existingModel) { + delete next.modelProvider; + } } - return { ...existing, ...patch, sessionId, updatedAt }; + return normalizeSessionRuntimeModelFields(next); } export function resolveFreshSessionTotalTokens( diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index bfc37d48249f..dd5c28ae616f 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -32,7 +32,11 @@ import { } from "../../auto-reply/thinking.js"; import type { CliDeps } from "../../cli/outbound-send-deps.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { resolveSessionTranscriptPath, updateSessionStore } from "../../config/sessions.js"; +import { + resolveSessionTranscriptPath, + setSessionRuntimeModel, + updateSessionStore, +} from "../../config/sessions.js"; import type { AgentDefaultsConfig } from "../../config/types.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { logWarn } from "../../logger.js"; @@ -481,8 +485,10 @@ export async function runCronIsolatedAgentTurn(params: { const contextTokens = agentCfg?.contextTokens ?? lookupContextTokens(modelUsed) ?? DEFAULT_CONTEXT_TOKENS; - cronSession.sessionEntry.modelProvider = providerUsed; - cronSession.sessionEntry.model = modelUsed; + setSessionRuntimeModel(cronSession.sessionEntry, { + provider: providerUsed, + model: modelUsed, + }); cronSession.sessionEntry.contextTokens = contextTokens; if (isCliProvider(providerUsed, cfgWithAgentDefaults)) { const cliSessionId = runResult.meta?.agentMeta?.sessionId?.trim(); diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 8813ad065f6c..357d1f4e5639 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -387,6 +387,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { reasoningLevel: entry?.reasoningLevel, responseUsage: entry?.responseUsage, model: entry?.model, + modelProvider: entry?.modelProvider, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, label: entry?.label, diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 0ffa73c92703..b05cf2220ede 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -202,6 +202,8 @@ describe("gateway server sessions", () => { main: { sessionId: "sess-main", updatedAt: recent, + modelProvider: "anthropic", + model: "claude-sonnet-4-6", inputTokens: 10, outputTokens: 20, thinkingLevel: "low", @@ -456,11 +458,13 @@ describe("gateway server sessions", () => { const reset = await rpcReq<{ ok: true; key: string; - entry: { sessionId: string }; + entry: { sessionId: string; modelProvider?: string; model?: string }; }>(ws, "sessions.reset", { key: "agent:main:main" }); expect(reset.ok).toBe(true); expect(reset.payload?.key).toBe("agent:main:main"); expect(reset.payload?.entry.sessionId).not.toBe("sess-main"); + expect(reset.payload?.entry.modelProvider).toBe("anthropic"); + expect(reset.payload?.entry.model).toBe("claude-sonnet-4-6"); const filesAfterReset = await fs.readdir(dir); expect(filesAfterReset.some((f) => f.startsWith("sess-main.jsonl.reset."))).toBe(true); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 5ad550eb0ed2..b86e3be142e3 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -13,6 +13,7 @@ import { parseGroupKey, pruneLegacyStoreKeys, resolveGatewaySessionStoreTarget, + resolveSessionModelIdentityRef, resolveSessionModelRef, resolveSessionStoreKey, } from "./session-utils.js"; @@ -339,6 +340,159 @@ describe("resolveSessionModelRef", () => { expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.3-codex" }); }); + + test("falls back to resolved provider for unprefixed legacy runtime model", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelRef(cfg, { + sessionId: "legacy-session", + updatedAt: Date.now(), + model: "claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ + provider: "google-gemini-cli", + model: "claude-sonnet-4-6", + }); + }); + + test("preserves provider from slash-prefixed model when modelProvider is missing", () => { + // When model string contains a provider prefix (e.g. "anthropic/claude-sonnet-4-6") + // parseModelRef should extract it correctly even without modelProvider set. + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelRef(cfg, { + sessionId: "slash-model", + updatedAt: Date.now(), + model: "anthropic/claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" }); + }); +}); + +describe("resolveSessionModelIdentityRef", () => { + test("does not inherit default provider for unprefixed legacy runtime model", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "legacy-session", + updatedAt: Date.now(), + model: "claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ model: "claude-sonnet-4-6" }); + }); + + test("infers provider from configured model allowlist when unambiguous", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + models: { + "anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "legacy-session", + updatedAt: Date.now(), + model: "claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" }); + }); + + test("keeps provider unknown when configured models are ambiguous", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + models: { + "anthropic/claude-sonnet-4-6": {}, + "minimax/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "legacy-session", + updatedAt: Date.now(), + model: "claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ model: "claude-sonnet-4-6" }); + }); + + test("preserves provider from slash-prefixed runtime model", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "slash-model", + updatedAt: Date.now(), + model: "anthropic/claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" }); + }); + + test("infers wrapper provider for slash-prefixed runtime model when allowlist match is unique", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + models: { + "vercel-ai-gateway/anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "slash-model", + updatedAt: Date.now(), + model: "anthropic/claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ + provider: "vercel-ai-gateway", + model: "anthropic/claude-sonnet-4-6", + }); + }); }); describe("deriveSessionTitle", () => { @@ -529,6 +683,99 @@ describe("listSessionsFromStore search", () => { expect(result.sessions.map((session) => session.key)).toEqual(["agent:main:cron:job-1"]); }); + test("does not guess provider for legacy runtime model without modelProvider", () => { + const cfg = { + session: { mainKey: "main" }, + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + }, + }, + } as OpenClawConfig; + const now = Date.now(); + const store: Record = { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: now, + model: "claude-sonnet-4-6", + } as SessionEntry, + }; + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + expect(result.sessions[0]?.modelProvider).toBeUndefined(); + expect(result.sessions[0]?.model).toBe("claude-sonnet-4-6"); + }); + + test("infers provider for legacy runtime model when allowlist match is unique", () => { + const cfg = { + session: { mainKey: "main" }, + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + models: { + "anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + const now = Date.now(); + const store: Record = { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: now, + model: "claude-sonnet-4-6", + } as SessionEntry, + }; + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + expect(result.sessions[0]?.modelProvider).toBe("anthropic"); + expect(result.sessions[0]?.model).toBe("claude-sonnet-4-6"); + }); + + test("infers wrapper provider for slash-prefixed legacy runtime model when allowlist match is unique", () => { + const cfg = { + session: { mainKey: "main" }, + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + models: { + "vercel-ai-gateway/anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + const now = Date.now(); + const store: Record = { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: now, + model: "anthropic/claude-sonnet-4-6", + } as SessionEntry, + }; + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + expect(result.sessions[0]?.modelProvider).toBe("vercel-ai-gateway"); + expect(result.sessions[0]?.model).toBe("anthropic/claude-sonnet-4-6"); + }); + test("exposes unknown totals when freshness is stale or missing", () => { const now = Date.now(); const store: Record = { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 73dbd9c71be9..14165ab28754 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -4,6 +4,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { + inferUniqueProviderFromConfiguredModels, parseModelRef, resolveConfiguredModelRef, resolveDefaultModelForAgent, @@ -692,6 +693,39 @@ export function resolveSessionModelRef( return { provider, model }; } +export function resolveSessionModelIdentityRef( + cfg: OpenClawConfig, + entry?: + | SessionEntry + | Pick, + agentId?: string, +): { provider?: string; model: string } { + const runtimeModel = entry?.model?.trim(); + const runtimeProvider = entry?.modelProvider?.trim(); + if (runtimeModel) { + if (runtimeProvider) { + return { provider: runtimeProvider, model: runtimeModel }; + } + const inferredProvider = inferUniqueProviderFromConfiguredModels({ + cfg, + model: runtimeModel, + }); + if (inferredProvider) { + return { provider: inferredProvider, model: runtimeModel }; + } + if (runtimeModel.includes("/")) { + const parsedRuntime = parseModelRef(runtimeModel, DEFAULT_PROVIDER); + if (parsedRuntime) { + return { provider: parsedRuntime.provider, model: parsedRuntime.model }; + } + return { model: runtimeModel }; + } + return { model: runtimeModel }; + } + const resolved = resolveSessionModelRef(cfg, entry, agentId); + return { provider: resolved.provider, model: resolved.model }; +} + export function listSessionsFromStore(params: { cfg: OpenClawConfig; storePath: string; @@ -782,8 +816,8 @@ export function listSessionsFromStore(params: { const deliveryFields = normalizeSessionDeliveryFields(entry); const parsedAgent = parseAgentSessionKey(key); const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg)); - const resolvedModel = resolveSessionModelRef(cfg, entry, sessionAgentId); - const modelProvider = resolvedModel.provider ?? DEFAULT_PROVIDER; + const resolvedModel = resolveSessionModelIdentityRef(cfg, entry, sessionAgentId); + const modelProvider = resolvedModel.provider; const model = resolvedModel.model ?? DEFAULT_MODEL; return { key, From def28a87b2420ff073a9f4d258258c7cc732fbb0 Mon Sep 17 00:00:00 2001 From: justinhuangcode Date: Tue, 24 Feb 2026 07:22:18 +0000 Subject: [PATCH 397/408] fix(slack): deliver file-only messages when all media downloads fail When a Slack message contains only files/audio (no text) and every file download fails, `resolveSlackMedia` returns null and `rawBody` becomes empty, causing `prepareSlackMessage` to silently drop the message. Build a fallback placeholder from the original file names so the agent still receives the message, matching the pattern already used in `resolveSlackThreadHistory` for file-only thread entries. Closes #25064 --- .../monitor/message-handler/prepare.test.ts | 17 +++++++++++++++++ src/slack/monitor/message-handler/prepare.ts | 15 ++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/slack/monitor/message-handler/prepare.test.ts b/src/slack/monitor/message-handler/prepare.test.ts index 3e8e8b2e8477..08bee35b7c0d 100644 --- a/src/slack/monitor/message-handler/prepare.test.ts +++ b/src/slack/monitor/message-handler/prepare.test.ts @@ -222,6 +222,23 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared).toBeNull(); }); + it("delivers file-only message with placeholder when media download fails", async () => { + // Files without url_private will fail to download, simulating a download + // failure. The message should still be delivered with a fallback + // placeholder instead of being silently dropped (#25064). + const prepared = await prepareWithDefaultCtx( + createSlackMessage({ + text: "", + files: [{ name: "voice.ogg" }, { name: "photo.jpg" }], + }), + ); + + expect(prepared).toBeTruthy(); + expect(prepared!.ctxPayload.RawBody).toContain("[Slack file:"); + expect(prepared!.ctxPayload.RawBody).toContain("voice.ogg"); + expect(prepared!.ctxPayload.RawBody).toContain("photo.jpg"); + }); + it("keeps channel metadata out of GroupSystemPrompt", async () => { const slackCtx = createInboundSlackCtx({ cfg: { diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 39515ad621d9..0073de14d11e 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -362,8 +362,21 @@ export async function prepareSlackMessage(params: { const mediaPlaceholder = effectiveDirectMedia ? effectiveDirectMedia.map((m) => m.placeholder).join(" ") : undefined; + + // When files were attached but all downloads failed, create a fallback + // placeholder so the message is still delivered to the agent instead of + // being silently dropped (#25064). + const fileOnlyFallback = + !mediaPlaceholder && (message.files?.length ?? 0) > 0 + ? message + .files!.slice(0, 5) + .map((f) => f.name ?? "file") + .join(", ") + : undefined; + const fileOnlyPlaceholder = fileOnlyFallback ? `[Slack file: ${fileOnlyFallback}]` : undefined; + const rawBody = - [(message.text ?? "").trim(), attachmentContent?.text, mediaPlaceholder] + [(message.text ?? "").trim(), attachmentContent?.text, mediaPlaceholder, fileOnlyPlaceholder] .filter(Boolean) .join("\n") || ""; if (!rawBody) { From a6337be3d17477204dc9c0dfe6e6587942eab930 Mon Sep 17 00:00:00 2001 From: justinhuangcode Date: Tue, 24 Feb 2026 11:34:43 +0000 Subject: [PATCH 398/408] refactor: use MAX_SLACK_MEDIA_FILES constant for file-only fallback Replace the hardcoded limit of 5 with the existing MAX_SLACK_MEDIA_FILES constant (8) from media.ts for consistency. Co-Authored-By: Claude Opus 4.6 --- src/slack/monitor/media.ts | 2 +- src/slack/monitor/message-handler/prepare.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/slack/monitor/media.ts b/src/slack/monitor/media.ts index 964aec1107a5..169e5571da0d 100644 --- a/src/slack/monitor/media.ts +++ b/src/slack/monitor/media.ts @@ -131,7 +131,7 @@ export type SlackMediaResult = { placeholder: string; }; -const MAX_SLACK_MEDIA_FILES = 8; +export const MAX_SLACK_MEDIA_FILES = 8; const MAX_SLACK_MEDIA_CONCURRENCY = 3; const MAX_SLACK_FORWARDED_ATTACHMENTS = 8; diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 0073de14d11e..cd2b758cddb5 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -44,6 +44,7 @@ import { stripSlackMentionsForCommandDetection } from "../commands.js"; import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js"; import { resolveSlackAttachmentContent, + MAX_SLACK_MEDIA_FILES, resolveSlackMedia, resolveSlackThreadHistory, resolveSlackThreadStarter, @@ -369,7 +370,7 @@ export async function prepareSlackMessage(params: { const fileOnlyFallback = !mediaPlaceholder && (message.files?.length ?? 0) > 0 ? message - .files!.slice(0, 5) + .files!.slice(0, MAX_SLACK_MEDIA_FILES) .map((f) => f.name ?? "file") .join(", ") : undefined; From b247cd6d65e63b2ee17ae7c9687431f264a40e91 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 05:36:06 +0000 Subject: [PATCH 399/408] fix: harden Slack file-only fallback placeholder (#25181) (thanks @justinhuangcode) --- CHANGELOG.md | 1 + src/slack/monitor/message-handler/prepare.test.ts | 12 ++++++++++++ src/slack/monitor/message-handler/prepare.ts | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfd0c5514837..f6e1a71e0c9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin. - Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck. - Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3. +- Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to `file` so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode. ## 2026.2.24 diff --git a/src/slack/monitor/message-handler/prepare.test.ts b/src/slack/monitor/message-handler/prepare.test.ts index 08bee35b7c0d..548e2b0b4715 100644 --- a/src/slack/monitor/message-handler/prepare.test.ts +++ b/src/slack/monitor/message-handler/prepare.test.ts @@ -239,6 +239,18 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared!.ctxPayload.RawBody).toContain("photo.jpg"); }); + it("falls back to generic file label when a Slack file name is empty", async () => { + const prepared = await prepareWithDefaultCtx( + createSlackMessage({ + text: "", + files: [{ name: "" }], + }), + ); + + expect(prepared).toBeTruthy(); + expect(prepared!.ctxPayload.RawBody).toContain("[Slack file: file]"); + }); + it("keeps channel metadata out of GroupSystemPrompt", async () => { const slackCtx = createInboundSlackCtx({ cfg: { diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index cd2b758cddb5..6a0121d996ed 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -371,7 +371,7 @@ export async function prepareSlackMessage(params: { !mediaPlaceholder && (message.files?.length ?? 0) > 0 ? message .files!.slice(0, MAX_SLACK_MEDIA_FILES) - .map((f) => f.name ?? "file") + .map((f) => f.name?.trim() || "file") .join(", ") : undefined; const fileOnlyPlaceholder = fileOnlyFallback ? `[Slack file: ${fileOnlyFallback}]` : undefined; From e5399835b211079b745130608df89794b409e8a1 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 11:13:37 +0530 Subject: [PATCH 400/408] fix(android): normalize canvas host URLs for TLS gateways --- .../android/gateway/GatewaySession.kt | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt index b7040d2ae271..0f8bb1ac7ed0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt @@ -336,7 +336,7 @@ class GatewaySession( deviceAuthStore.saveToken(deviceId, authRole, deviceToken) } val rawCanvas = obj["canvasHostUrl"].asStringOrNull() - canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint) + canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null) val sessionDefaults = obj["snapshot"].asObjectOrNull() ?.get("sessionDefaults").asObjectOrNull() @@ -625,24 +625,33 @@ class GatewaySession( return parts.joinToString("|") } - private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? { + private fun normalizeCanvasHostUrl( + raw: String?, + endpoint: GatewayEndpoint, + isTlsConnection: Boolean, + ): String? { val trimmed = raw?.trim().orEmpty() val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { java.net.URI(it) }.getOrNull() } val host = parsed?.host?.trim().orEmpty() val port = parsed?.port ?: -1 val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" } - // Detect TLS reverse proxy: endpoint on port 443, or domain-based host - val tls = endpoint.port == 443 || endpoint.host.contains(".") - - // If raw URL is a non-loopback address AND we're behind TLS reverse proxy, - // fix the port (gateway sends its internal port like 18789, but we need 443 via Caddy) + // If raw URL is a non-loopback address and this connection uses TLS, + // normalize scheme/port to the endpoint we actually connected to. if (trimmed.isNotBlank() && !isLoopbackHost(host)) { - if (tls && port > 0 && port != 443) { - // Rewrite the URL to use the reverse proxy port instead of the raw gateway port + val needsTlsRewrite = + isTlsConnection && + ( + !scheme.equals("https", ignoreCase = true) || + (port > 0 && port != endpoint.port) || + (port <= 0 && endpoint.port != 443) + ) + if (needsTlsRewrite) { val fixedScheme = "https" val formattedHost = if (host.contains(":")) "[${host}]" else host - return "$fixedScheme://$formattedHost" + val fixedPort = endpoint.port + val portSuffix = if (fixedPort == 443) "" else ":$fixedPort" + return "$fixedScheme://$formattedHost$portSuffix" } return trimmed } @@ -653,11 +662,10 @@ class GatewaySession( ?: endpoint.host.trim() if (fallbackHost.isEmpty()) return trimmed.ifBlank { null } - // When connecting through a reverse proxy (TLS on standard port), use the - // connection endpoint's scheme and port instead of the raw canvas port. - val fallbackScheme = if (tls) "https" else scheme - // Behind reverse proxy, always use the proxy port (443), not the raw canvas port - val fallbackPort = if (tls) endpoint.port else (endpoint.canvasPort ?: endpoint.port) + // For TLS connections, use the connected endpoint's scheme/port instead of raw canvas metadata. + val fallbackScheme = if (isTlsConnection) "https" else scheme + // For TLS, always use the connected endpoint port. + val fallbackPort = if (isTlsConnection) endpoint.port else (endpoint.canvasPort ?: endpoint.port) val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost val portSuffix = if ((fallbackScheme == "https" && fallbackPort == 443) || (fallbackScheme == "http" && fallbackPort == 80)) "" else ":$fallbackPort" return "$fallbackScheme://$formattedHost$portSuffix" From 1c0c58e30dadfe263350fe3029d178384e24be90 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 11:13:42 +0530 Subject: [PATCH 401/408] feat(android): add screen-tab canvas restore flow --- .../java/ai/openclaw/android/MainViewModel.kt | 8 ++ .../java/ai/openclaw/android/NodeRuntime.kt | 73 +++++++++++++++++++ .../openclaw/android/node/CanvasController.kt | 12 +++ .../openclaw/android/node/InvokeDispatcher.kt | 4 + .../openclaw/android/ui/PostOnboardingTabs.kt | 44 ++++++++++- 5 files changed, 140 insertions(+), 1 deletion(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt index 70aa176922c6..bcfd657694ec 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt @@ -14,6 +14,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { private val runtime: NodeRuntime = (app as NodeApp).runtime val canvas: CanvasController = runtime.canvas + val canvasCurrentUrl: StateFlow = runtime.canvas.currentUrl + val canvasA2uiHydrated: StateFlow = runtime.canvasA2uiHydrated + val canvasRehydratePending: StateFlow = runtime.canvasRehydratePending + val canvasRehydrateErrorText: StateFlow = runtime.canvasRehydrateErrorText val camera: CameraCaptureManager = runtime.camera val screenRecorder: ScreenRecordManager = runtime.screenRecorder val sms: SmsManager = runtime.sms @@ -171,6 +175,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.handleCanvasA2UIActionFromWebView(payloadJson) } + fun requestCanvasRehydrate(source: String = "screen_tab") { + runtime.requestCanvasRehydrate(source = source, force = true) + } + fun loadChat(sessionKey: String) { runtime.loadChat(sessionKey) } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt index 5e6268511aab..9cf06a0b6213 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt @@ -164,6 +164,12 @@ class NodeRuntime(context: Context) { isForeground = { _isForeground.value }, cameraEnabled = { cameraEnabled.value }, locationEnabled = { locationMode.value != LocationMode.Off }, + onCanvasA2uiPush = { + _canvasA2uiHydrated.value = true + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null + }, + onCanvasA2uiReset = { _canvasA2uiHydrated.value = false }, ) private lateinit var gatewayEventHandler: GatewayEventHandler @@ -195,6 +201,13 @@ class NodeRuntime(context: Context) { private val _screenRecordActive = MutableStateFlow(false) val screenRecordActive: StateFlow = _screenRecordActive.asStateFlow() + private val _canvasA2uiHydrated = MutableStateFlow(false) + val canvasA2uiHydrated: StateFlow = _canvasA2uiHydrated.asStateFlow() + private val _canvasRehydratePending = MutableStateFlow(false) + val canvasRehydratePending: StateFlow = _canvasRehydratePending.asStateFlow() + private val _canvasRehydrateErrorText = MutableStateFlow(null) + val canvasRehydrateErrorText: StateFlow = _canvasRehydrateErrorText.asStateFlow() + private val _serverName = MutableStateFlow(null) val serverName: StateFlow = _serverName.asStateFlow() @@ -208,6 +221,8 @@ class NodeRuntime(context: Context) { val isForeground: StateFlow = _isForeground.asStateFlow() private var lastAutoA2uiUrl: String? = null + private var didAutoRequestCanvasRehydrate = false + private val canvasRehydrateSeq = AtomicLong(0) private var operatorConnected = false private var nodeConnected = false private var operatorStatusText: String = "Offline" @@ -257,12 +272,21 @@ class NodeRuntime(context: Context) { onConnected = { _, _, _ -> nodeConnected = true nodeStatusText = "Connected" + didAutoRequestCanvasRehydrate = false + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null updateStatus() maybeNavigateToA2uiOnConnect() + requestCanvasRehydrate(source = "node_connect", force = false) }, onDisconnected = { message -> nodeConnected = false nodeStatusText = message + didAutoRequestCanvasRehydrate = false + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null updateStatus() showLocalCanvasOnDisconnect() }, @@ -329,9 +353,58 @@ class NodeRuntime(context: Context) { private fun showLocalCanvasOnDisconnect() { lastAutoA2uiUrl = null + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null canvas.navigate("") } + fun requestCanvasRehydrate(source: String = "manual", force: Boolean = true) { + scope.launch { + if (!nodeConnected) return@launch + if (!force && didAutoRequestCanvasRehydrate) return@launch + didAutoRequestCanvasRehydrate = true + val requestId = canvasRehydrateSeq.incrementAndGet() + _canvasRehydratePending.value = true + _canvasRehydrateErrorText.value = null + + val sessionKey = resolveMainSessionKey() + val prompt = + "Restore canvas now for session=$sessionKey source=$source. " + + "If existing A2UI state exists, replay it immediately. " + + "If not, create and render a compact mobile-friendly dashboard in Canvas." + try { + nodeSession.sendNodeEvent( + event = "agent.request", + payloadJson = + buildJsonObject { + put("message", JsonPrimitive(prompt)) + put("sessionKey", JsonPrimitive(sessionKey)) + put("thinking", JsonPrimitive("low")) + put("deliver", JsonPrimitive(false)) + }.toString(), + ) + scope.launch { + delay(20_000) + if (canvasRehydrateSeq.get() != requestId) return@launch + if (!_canvasRehydratePending.value) return@launch + if (_canvasA2uiHydrated.value) return@launch + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = "No canvas update yet. Tap to retry." + } + } catch (err: Throwable) { + if (!force) { + didAutoRequestCanvasRehydrate = false + } + if (canvasRehydrateSeq.get() == requestId) { + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = "Failed to request restore. Tap to retry." + } + Log.w("OpenClawCanvas", "canvas rehydrate request failed (${source}): ${err.message}") + } + } + } + val instanceId: StateFlow = prefs.instanceId val displayName: StateFlow = prefs.displayName val cameraEnabled: StateFlow = prefs.cameraEnabled diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt index c46770a6367f..d0747ee32b00 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt @@ -10,6 +10,9 @@ import androidx.core.graphics.scale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import java.io.ByteArrayOutputStream import android.util.Base64 import org.json.JSONObject @@ -31,6 +34,8 @@ class CanvasController { @Volatile private var debugStatusEnabled: Boolean = false @Volatile private var debugStatusTitle: String? = null @Volatile private var debugStatusSubtitle: String? = null + private val _currentUrl = MutableStateFlow(null) + val currentUrl: StateFlow = _currentUrl.asStateFlow() private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html" @@ -45,9 +50,16 @@ class CanvasController { applyDebugStatus() } + fun detach(webView: WebView) { + if (this.webView === webView) { + this.webView = null + } + } + fun navigate(url: String) { val trimmed = url.trim() this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed + _currentUrl.value = this.url reload() } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt index e44896db0fa1..91e9da8add14 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt @@ -20,6 +20,8 @@ class InvokeDispatcher( private val isForeground: () -> Boolean, private val cameraEnabled: () -> Boolean, private val locationEnabled: () -> Boolean, + private val onCanvasA2uiPush: () -> Unit, + private val onCanvasA2uiReset: () -> Unit, ) { suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult { // Check foreground requirement for canvas/camera/screen commands @@ -117,6 +119,7 @@ class InvokeDispatcher( ) } val res = canvas.eval(A2UIHandler.a2uiResetJS) + onCanvasA2uiReset() GatewaySession.InvokeResult.ok(res) } OpenClawCanvasA2UICommand.Push.rawValue, OpenClawCanvasA2UICommand.PushJSONL.rawValue -> { @@ -143,6 +146,7 @@ class InvokeDispatcher( } val js = A2UIHandler.a2uiApplyMessagesJS(messages) val res = canvas.eval(js) + onCanvasA2uiPush() GatewaySession.InvokeResult.ok(res) } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt index 64b8100a44fe..d61a107e7ed8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt @@ -115,13 +115,55 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel) HomeTab.Chat -> ChatSheet(viewModel = viewModel) HomeTab.Voice -> ComingSoonTabScreen(label = "VOICE", title = "Coming soon", description = "Voice mode is coming soon.") - HomeTab.Screen -> ComingSoonTabScreen(label = "SCREEN", title = "Coming soon", description = "Screen mode is coming soon.") + HomeTab.Screen -> ScreenTabScreen(viewModel = viewModel) HomeTab.Settings -> SettingsSheet(viewModel = viewModel) } } } } +@Composable +private fun ScreenTabScreen(viewModel: MainViewModel) { + val isConnected by viewModel.isConnected.collectAsState() + val canvasUrl by viewModel.canvasCurrentUrl.collectAsState() + val canvasA2uiHydrated by viewModel.canvasA2uiHydrated.collectAsState() + val canvasRehydratePending by viewModel.canvasRehydratePending.collectAsState() + val canvasRehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState() + val isA2uiUrl = canvasUrl?.contains("/__openclaw__/a2ui/") == true + val showRestoreCta = isConnected && (canvasUrl.isNullOrBlank() || (isA2uiUrl && !canvasA2uiHydrated)) + val restoreCtaText = + when { + canvasRehydratePending -> "Restore requested. Waiting for agent…" + !canvasRehydrateErrorText.isNullOrBlank() -> canvasRehydrateErrorText!! + else -> "Canvas reset. Tap to restore dashboard." + } + + Box(modifier = Modifier.fillMaxSize()) { + CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize()) + + if (showRestoreCta) { + Surface( + onClick = { + if (canvasRehydratePending) return@Surface + viewModel.requestCanvasRehydrate(source = "screen_tab_cta") + }, + modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = 16.dp, vertical = 16.dp), + shape = RoundedCornerShape(12.dp), + color = mobileSurface.copy(alpha = 0.9f), + border = BorderStroke(1.dp, mobileBorder), + shadowElevation = 4.dp, + ) { + Text( + text = restoreCtaText, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = mobileCallout.copy(fontWeight = FontWeight.Medium), + color = mobileText, + ) + } + } + } +} + @Composable private fun TopStatusBar( statusText: String, From 35a4641bb63c8f2779a20ba5192f8cb6d4e421a7 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 11:13:44 +0530 Subject: [PATCH 402/408] fix(android): use mobile viewport settings for canvas webview --- .../ai/openclaw/android/ui/CanvasScreen.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt index 4faf7791fea7..f733d154ed95 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt @@ -13,6 +13,9 @@ import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView @@ -25,6 +28,18 @@ import ai.openclaw.android.MainViewModel fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) { val context = LocalContext.current val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 + val webViewRef = remember { mutableStateOf(null) } + + DisposableEffect(viewModel) { + onDispose { + val webView = webViewRef.value ?: return@onDispose + viewModel.canvas.detach(webView) + webView.removeJavascriptInterface(CanvasA2UIActionBridge.interfaceName) + webView.stopLoading() + webView.destroy() + webViewRef.value = null + } + } AndroidView( modifier = modifier, @@ -33,6 +48,11 @@ fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) { settings.javaScriptEnabled = true settings.domStorageEnabled = true settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + settings.useWideViewPort = false + settings.loadWithOverviewMode = false + settings.builtInZoomControls = false + settings.displayZoomControls = false + settings.setSupportZoom(false) if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) } else { @@ -104,6 +124,7 @@ fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) { val bridge = CanvasA2UIActionBridge { payload -> viewModel.handleCanvasA2UIActionFromWebView(payload) } addJavascriptInterface(bridge, CanvasA2UIActionBridge.interfaceName) viewModel.canvas.attach(this) + webViewRef.value = this } }, ) From f701224a699feb01873fd7bbf4732cdeca4d2d84 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 11:15:27 +0530 Subject: [PATCH 403/408] feat(canvas): add narrow-screen A2UI layout overrides --- .../OpenClawKit/Tools/CanvasA2UI/bootstrap.js | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js b/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js index a9cb659876a5..530287ca21db 100644 --- a/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js +++ b/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js @@ -32,6 +32,66 @@ if (modalElement && Array.isArray(modalElement.styles)) { modalElement.styles = [...modalElement.styles, modalStyles]; } +const appendComponentStyles = (tagName, extraStyles) => { + const component = customElements.get(tagName); + if (!component) { + return; + } + + const current = component.styles; + if (!current) { + component.styles = [extraStyles]; + return; + } + + component.styles = Array.isArray(current) ? [...current, extraStyles] : [current, extraStyles]; +}; + +appendComponentStyles( + "a2ui-row", + css` + @media (max-width: 860px) { + section { + flex-wrap: wrap; + align-content: flex-start; + } + + ::slotted(*) { + flex: 1 1 100%; + min-width: 100%; + width: 100%; + max-width: 100%; + } + } + `, +); + +appendComponentStyles( + "a2ui-column", + css` + :host { + min-width: 0; + } + + section { + min-width: 0; + } + `, +); + +appendComponentStyles( + "a2ui-card", + css` + :host { + min-width: 0; + } + + section { + min-width: 0; + } + `, +); + const emptyClasses = () => ({}); const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} }); From 41870fac165aa90beadf4f55378e112c994e624f Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 11:31:35 +0530 Subject: [PATCH 404/408] fix(android): preserve scoped canvas URL suffix on TLS rewrite --- .../android/gateway/GatewaySession.kt | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt index 0f8bb1ac7ed0..fa26ffa2274e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt @@ -635,10 +635,11 @@ class GatewaySession( val host = parsed?.host?.trim().orEmpty() val port = parsed?.port ?: -1 val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" } + val suffix = buildUrlSuffix(parsed) // If raw URL is a non-loopback address and this connection uses TLS, // normalize scheme/port to the endpoint we actually connected to. - if (trimmed.isNotBlank() && !isLoopbackHost(host)) { + if (trimmed.isNotBlank() && host.isNotBlank() && !isLoopbackHost(host)) { val needsTlsRewrite = isTlsConnection && ( @@ -647,11 +648,7 @@ class GatewaySession( (port <= 0 && endpoint.port != 443) ) if (needsTlsRewrite) { - val fixedScheme = "https" - val formattedHost = if (host.contains(":")) "[${host}]" else host - val fixedPort = endpoint.port - val portSuffix = if (fixedPort == 443) "" else ":$fixedPort" - return "$fixedScheme://$formattedHost$portSuffix" + return buildCanvasUrl(host = host, scheme = "https", port = endpoint.port, suffix = suffix) } return trimmed } @@ -666,9 +663,22 @@ class GatewaySession( val fallbackScheme = if (isTlsConnection) "https" else scheme // For TLS, always use the connected endpoint port. val fallbackPort = if (isTlsConnection) endpoint.port else (endpoint.canvasPort ?: endpoint.port) - val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost - val portSuffix = if ((fallbackScheme == "https" && fallbackPort == 443) || (fallbackScheme == "http" && fallbackPort == 80)) "" else ":$fallbackPort" - return "$fallbackScheme://$formattedHost$portSuffix" + return buildCanvasUrl(host = fallbackHost, scheme = fallbackScheme, port = fallbackPort, suffix = suffix) + } + + private fun buildCanvasUrl(host: String, scheme: String, port: Int, suffix: String): String { + val loweredScheme = scheme.lowercase() + val formattedHost = if (host.contains(":")) "[${host}]" else host + val portSuffix = if ((loweredScheme == "https" && port == 443) || (loweredScheme == "http" && port == 80)) "" else ":$port" + return "$loweredScheme://$formattedHost$portSuffix$suffix" + } + + private fun buildUrlSuffix(uri: java.net.URI?): String { + if (uri == null) return "" + val path = uri.rawPath?.takeIf { it.isNotBlank() } ?: "" + val query = uri.rawQuery?.takeIf { it.isNotBlank() }?.let { "?$it" } ?: "" + val fragment = uri.rawFragment?.takeIf { it.isNotBlank() }?.let { "#$it" } ?: "" + return "$path$query$fragment" } private fun isLoopbackHost(raw: String?): Boolean { From b065265b73ead98cc81dd8c80e9ebbe1ab8d5447 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 11:31:45 +0530 Subject: [PATCH 405/408] fix(android): gate canvas restore on node connectivity --- .../java/ai/openclaw/android/MainViewModel.kt | 1 + .../java/ai/openclaw/android/NodeRuntime.kt | 21 ++++++++++++------- .../openclaw/android/ui/PostOnboardingTabs.kt | 3 ++- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt index bcfd657694ec..7076f09a292f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt @@ -26,6 +26,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val discoveryStatusText: StateFlow = runtime.discoveryStatusText val isConnected: StateFlow = runtime.isConnected + val isNodeConnected: StateFlow = runtime.nodeConnected val statusText: StateFlow = runtime.statusText val serverName: StateFlow = runtime.serverName val remoteAddress: StateFlow = runtime.remoteAddress diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt index 9cf06a0b6213..f83672aa88ad 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt @@ -181,6 +181,8 @@ class NodeRuntime(context: Context) { private val _isConnected = MutableStateFlow(false) val isConnected: StateFlow = _isConnected.asStateFlow() + private val _nodeConnected = MutableStateFlow(false) + val nodeConnected: StateFlow = _nodeConnected.asStateFlow() private val _statusText = MutableStateFlow("Offline") val statusText: StateFlow = _statusText.asStateFlow() @@ -224,7 +226,6 @@ class NodeRuntime(context: Context) { private var didAutoRequestCanvasRehydrate = false private val canvasRehydrateSeq = AtomicLong(0) private var operatorConnected = false - private var nodeConnected = false private var operatorStatusText: String = "Offline" private var nodeStatusText: String = "Offline" @@ -270,7 +271,7 @@ class NodeRuntime(context: Context) { identityStore = identityStore, deviceAuthStore = deviceAuthStore, onConnected = { _, _, _ -> - nodeConnected = true + _nodeConnected.value = true nodeStatusText = "Connected" didAutoRequestCanvasRehydrate = false _canvasA2uiHydrated.value = false @@ -281,7 +282,7 @@ class NodeRuntime(context: Context) { requestCanvasRehydrate(source = "node_connect", force = false) }, onDisconnected = { message -> - nodeConnected = false + _nodeConnected.value = false nodeStatusText = message didAutoRequestCanvasRehydrate = false _canvasA2uiHydrated.value = false @@ -329,9 +330,9 @@ class NodeRuntime(context: Context) { _isConnected.value = operatorConnected _statusText.value = when { - operatorConnected && nodeConnected -> "Connected" - operatorConnected && !nodeConnected -> "Connected (node offline)" - !operatorConnected && nodeConnected -> "Connected (operator offline)" + operatorConnected && _nodeConnected.value -> "Connected" + operatorConnected && !_nodeConnected.value -> "Connected (node offline)" + !operatorConnected && _nodeConnected.value -> "Connected (operator offline)" operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText else -> nodeStatusText } @@ -361,7 +362,11 @@ class NodeRuntime(context: Context) { fun requestCanvasRehydrate(source: String = "manual", force: Boolean = true) { scope.launch { - if (!nodeConnected) return@launch + if (!_nodeConnected.value) { + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = "Node offline. Reconnect and retry." + return@launch + } if (!force && didAutoRequestCanvasRehydrate) return@launch didAutoRequestCanvasRehydrate = true val requestId = canvasRehydrateSeq.incrementAndGet() @@ -725,7 +730,7 @@ class NodeRuntime(context: Context) { contextJson = contextJson, ) - val connected = nodeConnected + val connected = _nodeConnected.value var error: String? = null if (connected) { try { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt index d61a107e7ed8..b68c06ff2ff9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt @@ -125,12 +125,13 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) @Composable private fun ScreenTabScreen(viewModel: MainViewModel) { val isConnected by viewModel.isConnected.collectAsState() + val isNodeConnected by viewModel.isNodeConnected.collectAsState() val canvasUrl by viewModel.canvasCurrentUrl.collectAsState() val canvasA2uiHydrated by viewModel.canvasA2uiHydrated.collectAsState() val canvasRehydratePending by viewModel.canvasRehydratePending.collectAsState() val canvasRehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState() val isA2uiUrl = canvasUrl?.contains("/__openclaw__/a2ui/") == true - val showRestoreCta = isConnected && (canvasUrl.isNullOrBlank() || (isA2uiUrl && !canvasA2uiHydrated)) + val showRestoreCta = isConnected && isNodeConnected && (canvasUrl.isNullOrBlank() || (isA2uiUrl && !canvasA2uiHydrated)) val restoreCtaText = when { canvasRehydratePending -> "Restore requested. Waiting for agent…" From 81752564e97d301d572bae2c7ef604336457b821 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 11:32:19 +0530 Subject: [PATCH 406/408] refactor(android): return sendNodeEvent status to callers --- .../java/ai/openclaw/android/NodeRuntime.kt | 29 ++++++++++--------- .../openclaw/android/chat/ChatController.kt | 6 +--- .../android/gateway/GatewaySession.kt | 6 ++-- .../openclaw/android/voice/TalkModeManager.kt | 8 ++--- 4 files changed, 24 insertions(+), 25 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt index f83672aa88ad..3e804ec8a076 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt @@ -378,7 +378,7 @@ class NodeRuntime(context: Context) { "Restore canvas now for session=$sessionKey source=$source. " + "If existing A2UI state exists, replay it immediately. " + "If not, create and render a compact mobile-friendly dashboard in Canvas." - try { + val sent = nodeSession.sendNodeEvent( event = "agent.request", payloadJson = @@ -389,15 +389,7 @@ class NodeRuntime(context: Context) { put("deliver", JsonPrimitive(false)) }.toString(), ) - scope.launch { - delay(20_000) - if (canvasRehydrateSeq.get() != requestId) return@launch - if (!_canvasRehydratePending.value) return@launch - if (_canvasA2uiHydrated.value) return@launch - _canvasRehydratePending.value = false - _canvasRehydrateErrorText.value = "No canvas update yet. Tap to retry." - } - } catch (err: Throwable) { + if (!sent) { if (!force) { didAutoRequestCanvasRehydrate = false } @@ -405,7 +397,16 @@ class NodeRuntime(context: Context) { _canvasRehydratePending.value = false _canvasRehydrateErrorText.value = "Failed to request restore. Tap to retry." } - Log.w("OpenClawCanvas", "canvas rehydrate request failed (${source}): ${err.message}") + Log.w("OpenClawCanvas", "canvas rehydrate request failed ($source): transport unavailable") + return@launch + } + scope.launch { + delay(20_000) + if (canvasRehydrateSeq.get() != requestId) return@launch + if (!_canvasRehydratePending.value) return@launch + if (_canvasA2uiHydrated.value) return@launch + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = "No canvas update yet. Tap to retry." } } } @@ -733,7 +734,7 @@ class NodeRuntime(context: Context) { val connected = _nodeConnected.value var error: String? = null if (connected) { - try { + val sent = nodeSession.sendNodeEvent( event = "agent.request", payloadJson = @@ -745,8 +746,8 @@ class NodeRuntime(context: Context) { put("key", JsonPrimitive(actionId)) }.toString(), ) - } catch (e: Throwable) { - error = e.message ?: "send failed" + if (!sent) { + error = "send failed" } } else { error = "gateway not connected" diff --git a/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt index b72357983d5a..335f3b0d70be 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt @@ -261,11 +261,7 @@ class ChatController( val key = _sessionKey.value try { if (supportsChatSubscribe) { - try { - session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") - } catch (_: Throwable) { - // best-effort - } + session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") } val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""") diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt index fa26ffa2274e..4e210de8fb99 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt @@ -131,8 +131,8 @@ class GatewaySession( fun currentCanvasHostUrl(): String? = canvasHostUrl fun currentMainSessionKey(): String? = mainSessionKey - suspend fun sendNodeEvent(event: String, payloadJson: String?) { - val conn = currentConnection ?: return + suspend fun sendNodeEvent(event: String, payloadJson: String?): Boolean { + val conn = currentConnection ?: return false val parsedPayload = payloadJson?.let { parseJsonOrNull(it) } val params = buildJsonObject { @@ -147,8 +147,10 @@ class GatewaySession( } try { conn.request("node.event", params, timeoutMs = 8_000) + return true } catch (err: Throwable) { Log.w("OpenClawGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}") + return false } } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt index 54bea53bd677..f00481982a33 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt @@ -385,12 +385,12 @@ class TalkModeManager( val key = sessionKey.trim() if (key.isEmpty()) return if (chatSubscribedSessionKey == key) return - try { - session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") + val sent = session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") + if (sent) { chatSubscribedSessionKey = key Log.d(tag, "chat.subscribe ok sessionKey=$key") - } catch (err: Throwable) { - Log.w(tag, "chat.subscribe failed sessionKey=$key err=${err.message ?: err::class.java.simpleName}") + } else { + Log.w(tag, "chat.subscribe failed sessionKey=$key") } } From 6bc7544a6a3262115f4728b47a64afc93978671b Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Tue, 24 Feb 2026 18:30:21 -0700 Subject: [PATCH 407/408] fix(telegram): fail closed on empty group allowFrom override --- src/telegram/bot.create-telegram-bot.test.ts | 23 ++++++++ src/telegram/group-access.base-access.test.ts | 56 +++++++++++++++++++ src/telegram/group-access.ts | 5 ++ 3 files changed, 84 insertions(+) create mode 100644 src/telegram/group-access.base-access.test.ts diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index ad304efdeab3..942a1c6c2b3f 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -813,6 +813,29 @@ describe("createTelegramBot", () => { }, expectedReplyCount: 1, }, + { + name: "blocks group messages when per-group allowFrom override is explicitly empty", + config: { + channels: { + telegram: { + groupPolicy: "open", + groups: { + "-100123456789": { + allowFrom: [], + requireMention: false, + }, + }, + }, + }, + }, + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "random" }, + text: "hello", + date: 1736380800, + }, + expectedReplyCount: 0, + }, { name: "allows all group messages when groupPolicy is 'open'", config: { diff --git a/src/telegram/group-access.base-access.test.ts b/src/telegram/group-access.base-access.test.ts new file mode 100644 index 000000000000..d8d559feab49 --- /dev/null +++ b/src/telegram/group-access.base-access.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import type { NormalizedAllowFrom } from "./bot-access.js"; +import { evaluateTelegramGroupBaseAccess } from "./group-access.js"; + +function allow(entries: string[], hasWildcard = false): NormalizedAllowFrom { + return { + entries, + hasWildcard, + hasEntries: entries.length > 0 || hasWildcard, + invalidEntries: [], + }; +} + +describe("evaluateTelegramGroupBaseAccess", () => { + it("fails closed when explicit group allowFrom override is empty", () => { + const result = evaluateTelegramGroupBaseAccess({ + isGroup: true, + hasGroupAllowOverride: true, + effectiveGroupAllow: allow([]), + senderId: "12345", + senderUsername: "tester", + enforceAllowOverride: true, + requireSenderForAllowOverride: true, + }); + + expect(result).toEqual({ allowed: false, reason: "group-override-unauthorized" }); + }); + + it("allows group message when override is not configured", () => { + const result = evaluateTelegramGroupBaseAccess({ + isGroup: true, + hasGroupAllowOverride: false, + effectiveGroupAllow: allow([]), + senderId: "12345", + senderUsername: "tester", + enforceAllowOverride: true, + requireSenderForAllowOverride: true, + }); + + expect(result).toEqual({ allowed: true }); + }); + + it("allows sender explicitly listed in override", () => { + const result = evaluateTelegramGroupBaseAccess({ + isGroup: true, + hasGroupAllowOverride: true, + effectiveGroupAllow: allow(["12345"]), + senderId: "12345", + senderUsername: "tester", + enforceAllowOverride: true, + requireSenderForAllowOverride: true, + }); + + expect(result).toEqual({ allowed: true }); + }); +}); diff --git a/src/telegram/group-access.ts b/src/telegram/group-access.ts index dcd0dd2ef6e5..1702277da6b5 100644 --- a/src/telegram/group-access.ts +++ b/src/telegram/group-access.ts @@ -42,6 +42,11 @@ export const evaluateTelegramGroupBaseAccess = (params: { return { allowed: true }; } + // Explicit per-group/topic allowFrom override must fail closed when empty. + if (!params.effectiveGroupAllow.hasEntries) { + return { allowed: false, reason: "group-override-unauthorized" }; + } + const senderId = params.senderId ?? ""; if (params.requireSenderForAllowOverride && !senderId) { return { allowed: false, reason: "group-override-unauthorized" }; From fb76e316fb443ddd678fbec4ec457ad3efd2b47d Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 11:58:52 +0530 Subject: [PATCH 408/408] fix(test): use valid brave ui_lang locale --- src/agents/tools/web-tools.enabled-defaults.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 0ffe8b586911..b129581f5a0a 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -128,7 +128,7 @@ describe("web_search country and language parameters", () => { it.each([ { key: "country", value: "DE" }, { key: "search_lang", value: "de" }, - { key: "ui_lang", value: "de" }, + { key: "ui_lang", value: "de-DE" }, { key: "freshness", value: "pw" }, ])("passes $key parameter to Brave API", async ({ key, value }) => { const url = await runBraveSearchAndGetUrl({ [key]: value });